mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(api-tokens): edit a token in a modal that opens without a request
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user