mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(api-tokens): edit token access and pause or resume tokens (#12396)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
263eab5d27
commit
76b89fa8e9
@@ -20,6 +20,17 @@ class ApiTokens extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* Expiry choice in the edit form that keeps the current expiry date.
|
||||
*/
|
||||
private const KEEP_EXPIRY = 'keep';
|
||||
|
||||
/**
|
||||
* The token loaded in the form for editing; null when the form creates a new token.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $editingTokenId = null;
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
/**
|
||||
@@ -186,37 +197,9 @@ class ApiTokens extends Component
|
||||
try {
|
||||
$this->authorize('create', PersonalAccessToken::class);
|
||||
|
||||
$validated = $this->validate([
|
||||
'description' => 'required|min:3|max:255',
|
||||
'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
|
||||
'permissions' => 'required|array|min:1',
|
||||
'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'delete', 'root'])],
|
||||
'scopeMode' => ['required', Rule::in(['team', 'selected'])],
|
||||
'selectedScopes' => ['exclude_unless:scopeMode,selected', 'required', 'array', 'min:1'],
|
||||
'selectedScopes.*' => ['string', 'regex:/^(project|environment|resource):[A-Za-z0-9_-]+$/'],
|
||||
], [
|
||||
'selectedScopes.required' => 'Select at least one project, environment, or resource.',
|
||||
]);
|
||||
$permissions = array_values($validated['permissions']);
|
||||
$teamId = currentTeam()->id;
|
||||
$scopes = $this->scopeMode === 'selected' ? $this->parseSelectedScopes($teamId) : null;
|
||||
|
||||
// Never trust the $this->canUse* booleans: check the current user's access in the team again.
|
||||
$resolver = app(AccessResolver::class);
|
||||
$isAdmin = auth()->user()->isAdminOfTeam($teamId);
|
||||
if (in_array('write:sensitive', $permissions, true) && ! $isAdmin) {
|
||||
throw new \Exception('You do not have permission to create tokens with write:sensitive permissions.');
|
||||
}
|
||||
$levels = AccessLevel::parseMany($permissions);
|
||||
$grants = $scopes === null
|
||||
? [Grant::team($levels)]
|
||||
: array_map(fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), $scopes);
|
||||
if (! $resolver->userHolds(auth()->user(), $teamId, $grants)) {
|
||||
throw new \Exception('A token cannot have more access than you have. Choose fewer permissions or narrower projects, environments, or resources.');
|
||||
}
|
||||
|
||||
$expiresAt = filled($validated['expiresInDays'] ?? null) ? now()->addDays((int) $validated['expiresInDays']) : null;
|
||||
$storedScopes = $scopes === null ? null : array_map(fn (Grant $scope) => ['scope' => $scope->scope->value, 'uuid' => $scope->uuid], $scopes);
|
||||
['permissions' => $permissions, 'scopes' => $storedScopes, 'expiresInDays' => $expiresInDays] = $this->validateTokenForm($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);
|
||||
@@ -239,6 +222,129 @@ class ApiTokens extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a token into the form, so its description, permissions, access, and expiry can be changed.
|
||||
*/
|
||||
public function startEditing(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);
|
||||
$before = ['abilities' => $token->abilities, 'scopes' => $token->scopes, 'expires_at' => $token->expires_at?->toIso8601String()];
|
||||
|
||||
$token->forceFill([
|
||||
'name' => $this->description,
|
||||
'abilities' => $permissions,
|
||||
'scopes' => $storedScopes,
|
||||
]);
|
||||
if ($expiresInDays !== self::KEEP_EXPIRY) {
|
||||
$token->forceFill([
|
||||
'expires_at' => filled($expiresInDays) ? now()->addDays((int) $expiresInDays) : null,
|
||||
// A new expiry date needs a new expiration warning.
|
||||
'api_token_expiration_warning_sent_at' => null,
|
||||
]);
|
||||
}
|
||||
$token->save();
|
||||
|
||||
auditLog('ui.api_token.updated', [
|
||||
'team_id' => $teamId,
|
||||
'api_token_name' => $token->name,
|
||||
'before' => $before,
|
||||
'after' => ['abilities' => $permissions, 'scopes' => $storedScopes, 'expires_at' => $token->expires_at?->toIso8601String()],
|
||||
]);
|
||||
$this->cancelEditing();
|
||||
$this->getTokens();
|
||||
$this->dispatch('success', 'API token updated.');
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the form and check that the user may give the token this access.
|
||||
* Never trust the $this->canUse* booleans: check the current user's access in the team again.
|
||||
*
|
||||
* @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
|
||||
{
|
||||
$expiryChoices = array_keys($this->expirationOptions);
|
||||
if ($editing) {
|
||||
$expiryChoices[] = self::KEEP_EXPIRY;
|
||||
}
|
||||
|
||||
$validated = $this->validate([
|
||||
'description' => 'required|min:3|max:255',
|
||||
'expiresInDays' => ['nullable', Rule::in($expiryChoices)],
|
||||
'permissions' => 'required|array|min:1',
|
||||
'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'delete', 'root'])],
|
||||
'scopeMode' => ['required', Rule::in(['team', 'selected'])],
|
||||
'selectedScopes' => ['exclude_unless:scopeMode,selected', 'required', 'array', 'min:1'],
|
||||
'selectedScopes.*' => ['string', 'regex:/^(project|environment|resource):[A-Za-z0-9_-]+$/'],
|
||||
], [
|
||||
'selectedScopes.required' => 'Select at least one project, environment, or resource.',
|
||||
]);
|
||||
$permissions = array_values($validated['permissions']);
|
||||
$scopes = $this->scopeMode === 'selected' ? $this->parseSelectedScopes($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.');
|
||||
}
|
||||
$levels = AccessLevel::parseMany($permissions);
|
||||
$grants = $scopes === null
|
||||
? [Grant::team($levels)]
|
||||
: array_map(fn (Grant $scope) => new Grant($scope->scope, $scope->uuid, $levels), $scopes);
|
||||
if (! app(AccessResolver::class)->userHolds(auth()->user(), $teamId, $grants)) {
|
||||
throw new \Exception('A token cannot have more access than you have. Choose fewer permissions or narrower projects, environments, or resources.');
|
||||
}
|
||||
|
||||
return [
|
||||
'permissions' => $permissions,
|
||||
'scopes' => $scopes === null ? null : array_map(fn (Grant $scope) => ['scope' => $scope->scope->value, 'uuid' => $scope->uuid], $scopes),
|
||||
'expiresInDays' => $validated['expiresInDays'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function findOwnToken(int $id): PersonalAccessToken
|
||||
{
|
||||
return auth()->user()->tokens()
|
||||
->where('team_id', currentTeam()->id)
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Grant> scopes without levels; a token's levels are its abilities
|
||||
*/
|
||||
@@ -264,13 +370,34 @@ class ApiTokens extends Component
|
||||
return $scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a token so it stops authenticating requests, or resume a paused token. The token value does not change.
|
||||
* The caller sends the wanted state, so a stale page cannot resume a token that another page paused.
|
||||
*/
|
||||
public function setPaused(int $id, bool $paused)
|
||||
{
|
||||
try {
|
||||
$token = $this->findOwnToken($id);
|
||||
$this->authorize('update', $token);
|
||||
|
||||
if ($token->isPaused() !== $paused) {
|
||||
$token->forceFill(['paused_at' => $paused ? now() : null])->save();
|
||||
auditLog($paused ? 'ui.api_token.paused' : 'ui.api_token.resumed', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'api_token_name' => $token->name,
|
||||
]);
|
||||
}
|
||||
$this->getTokens();
|
||||
$this->dispatch('success', $paused ? 'API token paused.' : 'API token resumed.');
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function roll(int $id)
|
||||
{
|
||||
try {
|
||||
$token = auth()->user()->tokens()
|
||||
->where('team_id', currentTeam()->id)
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
$token = $this->findOwnToken($id);
|
||||
$this->authorize('update', $token);
|
||||
$plainTextToken = $token->roll();
|
||||
auditLog('ui.api_token.rolled', [
|
||||
@@ -287,11 +414,11 @@ class ApiTokens extends Component
|
||||
public function revoke(int $id)
|
||||
{
|
||||
try {
|
||||
$token = auth()->user()->tokens()
|
||||
->where('team_id', currentTeam()->id)
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
$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', [
|
||||
|
||||
@@ -15,6 +15,7 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
|
||||
'api_token_expiration_warning_sent_at',
|
||||
'team_id',
|
||||
'scopes',
|
||||
'paused_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -22,9 +23,18 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
|
||||
return [
|
||||
'api_token_expiration_warning_sent_at' => 'datetime',
|
||||
'scopes' => 'array',
|
||||
'paused_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A paused token is kept, but it does not authenticate requests until it is resumed.
|
||||
*/
|
||||
public function isPaused(): bool
|
||||
{
|
||||
return $this->paused_at !== null;
|
||||
}
|
||||
|
||||
public static function generateSecret(): string
|
||||
{
|
||||
return sprintf(
|
||||
|
||||
@@ -108,6 +108,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
private function configureSanctumModel(): void
|
||||
{
|
||||
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
|
||||
Sanctum::authenticateAccessTokensUsing(
|
||||
fn (PersonalAccessToken $token, bool $isValid): bool => $isValid && ! $token->isPaused()
|
||||
);
|
||||
}
|
||||
|
||||
private function configureOidcSocialite(): void
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('personal_access_tokens', 'paused_at')) {
|
||||
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->timestamp('paused_at')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->dropColumn('paused_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3766,7 +3766,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.api-tokens-table-grid {
|
||||
grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem;
|
||||
grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 10.5rem;
|
||||
}
|
||||
|
||||
.fleet-servers-table-grid {
|
||||
@@ -3861,7 +3861,7 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.api-tokens-table-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(9rem, 1.3fr) 6.5rem 4.5rem;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(9rem, 1.3fr) 6.5rem 10.5rem;
|
||||
}
|
||||
|
||||
.api-tokens-table-grid > :nth-child(3),
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
->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">
|
||||
@@ -18,16 +26,31 @@
|
||||
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">
|
||||
<form wire:submit="{{ $editingToken ? 'updateToken' : 'addNewToken' }}" id="api-token-form">
|
||||
<x-application.settings-section :title="$editingToken ? 'Edit API token' : 'New API token'">
|
||||
<x-slot:actions>
|
||||
<button type="submit"
|
||||
class="button button-highlighted">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
Create token
|
||||
</button>
|
||||
@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
|
||||
</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" />
|
||||
@@ -208,8 +231,11 @@
|
||||
x-show="isVisible(@js((string) $token->id))"
|
||||
class="data-table-row api-tokens-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
|
||||
{{ $token->name }}
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="truncate text-[12px] font-medium text-black dark:text-fg">{{ $token->name }}</span>
|
||||
@if ($token->isPaused())
|
||||
<x-status-badge label="Paused" type="warning" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
@if ($token->scopes === null)
|
||||
@@ -245,8 +271,17 @@
|
||||
{{ $token->expires_at->format('Y-m-d') }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<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"
|
||||
|
||||
@@ -343,4 +343,127 @@ describe('token form', function () {
|
||||
|
||||
expect($member->tokens()->pluck('name')->all())->toBe(['shop']);
|
||||
});
|
||||
|
||||
test('edits a team-wide token to one project, and the same token value then sees only that project', function () {
|
||||
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read'], null);
|
||||
$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')
|
||||
->assertHasNoErrors()
|
||||
->assertNotDispatched('error')
|
||||
->assertSet('editingTokenId', null);
|
||||
|
||||
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]])
|
||||
->and(scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk()->json('*.name'))->toBe(['shop']);
|
||||
});
|
||||
|
||||
test('edits permissions and keeps the expiry, then sets a new expiry', function () {
|
||||
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read', 'write'], null);
|
||||
$token = PersonalAccessToken::query()->sole();
|
||||
$expiresAt = now()->addDays(10)->startOfSecond();
|
||||
$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')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token->refresh();
|
||||
expect($token->abilities)->toBe(['read'])
|
||||
->and($token->expires_at->equalTo($expiresAt))->toBeTrue()
|
||||
->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')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token->refresh();
|
||||
expect(round(now()->diffInDays($token->expires_at)))->toEqual(90)
|
||||
->and($token->api_token_expiration_warning_sent_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('a member cannot give a token more access than their grants by editing it', function () {
|
||||
$member = scopedTokenTestMember($this->team, [['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']]]);
|
||||
scopedTokenTestToken($member, $this->team, ['read'], [['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
||||
$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')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($token->fresh()->scopes)->toBe([['scope' => 'project', 'uuid' => $this->shop->uuid]]);
|
||||
});
|
||||
|
||||
test('cannot edit a token of another user or of another team', function () {
|
||||
$otherAdmin = User::factory()->create();
|
||||
$this->team->members()->attach($otherAdmin->id, ['role' => 'admin']);
|
||||
scopedTokenTestToken($otherAdmin, $this->team, ['read'], null);
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherTeam->members()->attach($this->admin->id, ['role' => 'admin']);
|
||||
scopedTokenTestToken($this->admin, $otherTeam, ['read'], null);
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
foreach (PersonalAccessToken::query()->get() as $token) {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->call('startEditing', $token->id)
|
||||
->assertSet('editingTokenId', null);
|
||||
}
|
||||
});
|
||||
|
||||
test('a paused token is rejected by the API and MCP until it is resumed', function () {
|
||||
$plainToken = scopedTokenTestToken($this->admin, $this->team, ['read'], null);
|
||||
$token = PersonalAccessToken::query()->sole();
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->call('setPaused', $token->id, true)
|
||||
->assertDispatched('success', 'API token paused.');
|
||||
|
||||
expect($token->fresh()->isPaused())->toBeTrue();
|
||||
|
||||
// A stale page that still shows "Pause" must not resume the token.
|
||||
Livewire::test(ApiTokens::class)->call('setPaused', $token->id, true);
|
||||
expect($token->fresh()->isPaused())->toBeTrue();
|
||||
scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertUnauthorized();
|
||||
scopedTokenTestMcp($plainToken, 'tools/list')->assertUnauthorized();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
Livewire::test(ApiTokens::class)
|
||||
->call('setPaused', $token->id, false)
|
||||
->assertDispatched('success', 'API token resumed.');
|
||||
|
||||
expect($token->fresh()->isPaused())->toBeFalse();
|
||||
scopedTokenTestApi($plainToken, 'GET', '/api/v1/projects')->assertOk();
|
||||
scopedTokenTestMcp($plainToken, 'tools/list')->assertOk();
|
||||
});
|
||||
|
||||
test('cannot pause a token of another user', function () {
|
||||
$otherAdmin = User::factory()->create();
|
||||
$this->team->members()->attach($otherAdmin->id, ['role' => 'admin']);
|
||||
scopedTokenTestToken($otherAdmin, $this->team, ['read'], null);
|
||||
$token = PersonalAccessToken::query()->sole();
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Livewire::test(ApiTokens::class)->call('setPaused', $token->id, true);
|
||||
|
||||
expect($token->fresh()->isPaused())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1173,6 +1173,9 @@ test('critical operational events persist with their source action and actor', f
|
||||
'ui.database.restore_started',
|
||||
'ui.scheduled_task.executed',
|
||||
'ui.api_token.created',
|
||||
'ui.api_token.updated',
|
||||
'ui.api_token.paused',
|
||||
'ui.api_token.resumed',
|
||||
'ui.api_token.revoked',
|
||||
'ui.team_member.role_updated',
|
||||
'ui.team_member.removed',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
seedBrowserResourceStack();
|
||||
$this->token = User::query()->where('email', 'test@example.com')->sole()
|
||||
->createToken('ci-token', ['read'], null, 0)->accessToken;
|
||||
});
|
||||
|
||||
it('edits a token from the token list and saves the changes', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit('/security/api-tokens')
|
||||
->assertSee('New API token')
|
||||
->click('button:visible:has-text("Edit")')
|
||||
->assertSee('Edit API token')
|
||||
->assertSee('Keep current (Never)')
|
||||
->assertValue('description', 'ci-token')
|
||||
->fill('description', 'deploy-token')
|
||||
->click('button:visible:has-text("Save changes")')
|
||||
->wait(1)
|
||||
->assertSee('New API token')
|
||||
->assertSee('deploy-token');
|
||||
|
||||
expect(PersonalAccessToken::query()->sole()->name)->toBe('deploy-token');
|
||||
$page->screenshot(filename: 'api-token-edited');
|
||||
});
|
||||
|
||||
it('cancels editing without saving', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit('/security/api-tokens')
|
||||
->click('button:visible:has-text("Edit")')
|
||||
->fill('description', 'changed')
|
||||
->click('button:visible:has-text("Cancel")')
|
||||
->wait(1)
|
||||
->assertSee('New API token')
|
||||
->assertValue('description', '');
|
||||
|
||||
expect($this->token->fresh()->name)->toBe('ci-token');
|
||||
$page->screenshot(filename: 'api-token-edit-cancelled');
|
||||
});
|
||||
|
||||
it('pauses and resumes a token from the token list', function () {
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit('/security/api-tokens')
|
||||
->click('button:visible:has-text("Pause")')
|
||||
->assertSee('API token paused.')
|
||||
->assertSee('Paused')
|
||||
->assertSee('Resume');
|
||||
|
||||
expect($this->token->fresh()->isPaused())->toBeTrue();
|
||||
$page->screenshot(filename: 'api-token-paused');
|
||||
|
||||
$page->click('button:visible:has-text("Resume")')
|
||||
->assertSee('API token resumed.');
|
||||
|
||||
expect($this->token->fresh()->isPaused())->toBeFalse();
|
||||
$page->screenshot(filename: 'api-token-resumed');
|
||||
});
|
||||
Reference in New Issue
Block a user