From a4886f6dfbe9e0a4405454a44dd3f057b536c297 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:29:03 +0200 Subject: [PATCH 01/11] feat(ui): add copy icon --- resources/views/components/reicon.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index 04471497f5..a46e4194cf 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -63,6 +63,7 @@ 'upload' => '', 'x' => '', 'check' => '', + 'copy' => '', 'chevron-down' => '', 'trash' => '', 'external-link' => '', From 4ee59442cef08918aaf5fdc3d99aac0157ace7f4 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:33:31 +0200 Subject: [PATCH 02/11] feat(ui): add shared copy button component --- resources/js/app.js | 2 ++ resources/js/copy-button.js | 35 +++++++++++++++++++ .../views/components/copy-button.blade.php | 28 ++++++--------- 3 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 resources/js/copy-button.js diff --git a/resources/js/app.js b/resources/js/app.js index bb41b7f041..900ef8af71 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,3 +1,4 @@ +import { initializeCopyButtonComponent } from './copy-button.js'; import { initializeTerminalComponent } from './terminal.js'; // Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate @@ -12,6 +13,7 @@ document.addEventListener('livewire:navigated', () => { // Keeping this registration independent from the current route also makes it // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); +document.addEventListener('alpine:init', initializeCopyButtonComponent); /** * Smooth-scroll a settings section into view, then flash its border for 500ms diff --git a/resources/js/copy-button.js b/resources/js/copy-button.js new file mode 100644 index 0000000000..0ce8d5d67d --- /dev/null +++ b/resources/js/copy-button.js @@ -0,0 +1,35 @@ +// Alpine data provider for the component (x-data="copyButton"). +export function initializeCopyButtonComponent() { + window.Alpine.data('copyButton', () => ({ + copied: false, + async copy(value) { + if (value === null || value === undefined) { + window.toast('Value is not available.', { type: 'warning' }); + return; + } + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + // Deprecated, but the only copy path on plain http (non-secure contexts). + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!ok) { + throw new Error('Copy command was rejected.'); + } + } + this.copied = true; + setTimeout(() => (this.copied = false), 1200); + } catch (e) { + window.toast('Could not copy to clipboard.', { type: 'warning' }); + } + }, + })); +} diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php index dfdceef20b..a266a272d8 100644 --- a/resources/views/components/copy-button.blade.php +++ b/resources/views/components/copy-button.blade.php @@ -1,22 +1,16 @@ @props([ - 'value', + 'value' => null, + 'resolve' => null, 'label' => 'Copy to clipboard', ]) - From 8757cd268657630f0f1919e978d8338098e38272 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:25:07 +0200 Subject: [PATCH 03/11] test: update tests for the new copy button component --- tests/Feature/CopyButtonComponentTest.php | 38 ++++++++++++++++--- .../Feature/ResourceDetailsVisibilityTest.php | 14 +++---- tests/Feature/TeamInvitationUiTest.php | 14 ++----- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/tests/Feature/CopyButtonComponentTest.php b/tests/Feature/CopyButtonComponentTest.php index a9996a062e..7177da08e8 100644 --- a/tests/Feature/CopyButtonComponentTest.php +++ b/tests/Feature/CopyButtonComponentTest.php @@ -1,16 +1,42 @@ blade(''); $html->assertSee('Copy backup path') ->assertSee('backup\/path.sql', false) - ->assertSee('window.copyToClipboard', false) - ->assertSee('size-6', false); + ->assertSee('x-data="copyButton"', false) + ->assertDontSee('window.copyToClipboard', false); }); -it('uses the reusable copy button for database backup paths', function () { - $view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php')); +it('disables the button when no backend value is available', function () { + $html = $this->blade(''); - expect($view)->toContain(''); + $html->assertSee('disabled', false); +}); + +it('evaluates a resolve expression at click time instead of a static value', function () { + $html = $this->blade(''); + + $html->assertSee('await ($wire.copyValue())', false) + ->assertDontSee('disabled', false); +}); + +it('is the single clipboard implementation shared by its call sites', function () { + expect(file_get_contents(resource_path('js/copy-button.js'))) + ->toContain("window.Alpine.data('copyButton'"); + + expect(file_get_contents(resource_path('js/app.js'))) + ->toContain('initializeCopyButtonComponent'); + + $modalConfirmation = file_get_contents(resource_path('views/components/modal-confirmation.blade.php')); + $backupExecutions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php')); + + expect($modalConfirmation) + ->toContain('not->toContain('navigator.clipboard'); + + expect($backupExecutions) + ->toContain('not->toContain('navigator.clipboard'); }); diff --git a/tests/Feature/ResourceDetailsVisibilityTest.php b/tests/Feature/ResourceDetailsVisibilityTest.php index 29f611cbaa..4cac570f7e 100644 --- a/tests/Feature/ResourceDetailsVisibilityTest.php +++ b/tests/Feature/ResourceDetailsVisibilityTest.php @@ -27,7 +27,7 @@ it('keeps the resource details helper text visible below the modal header', func ])->render(); expect($html) - ->toContain('Identifiers for this resource. Read-only') + ->toContain('readonly') ->toContain('pt-1') ->not->toContain('-mt-4'); }); @@ -38,20 +38,18 @@ it('renders copy fields as visible readonly controls with an accessible copy act expect($html) ->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"') ->toContain('readonly') - ->toContain('window.copyToClipboard') + ->toContain('x-data="copyButton"') ->toContain('input-with-copy-button') - ->toContain('copy-button') ->toContain('aria-label="Copy to clipboard"') - ->toContain('title="Copy to clipboard"') - ->toContain('class="size-[18px] text-green-500"'); + ->toContain('title="Copy to clipboard"'); }); -it('uses the shared copy field for newly issued api tokens', function () { +it('uses the shared copy button for newly issued api tokens', function () { $blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php')); expect($blade) - ->toContain('') - ->not->toContain('navigator.clipboard.writeText(@js(session(\'token\')))'); + ->toContain('not->toContain('navigator.clipboard'); }); it('keeps copy button padding above settings-workspace input overrides', function () { diff --git a/tests/Feature/TeamInvitationUiTest.php b/tests/Feature/TeamInvitationUiTest.php index 80a4a146df..38c8910986 100644 --- a/tests/Feature/TeamInvitationUiTest.php +++ b/tests/Feature/TeamInvitationUiTest.php @@ -51,25 +51,19 @@ it('renders a real copy button for pending invitation links', function () { $view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php')); expect($view) - ->toContain('aria-label="Copy invitation link"') - ->toContain('window.copyToClipboard(@js($invite->link))') - ->toContain('class="button h-7! shrink-0 px-2!"'); + ->toContain(''); Livewire::test(Invitations::class, [ 'invitations' => TeamInvitation::ownedByCurrentTeam()->get(), ]) ->assertSee($invitation->link) ->assertSeeHtml('aria-label="Copy invitation link"') - ->assertSeeHtml('window.copyToClipboard(') + ->assertSeeHtml('x-data="copyButton"') ->assertSeeHtml('type="button"'); }); -it('exposes a resilient global copyToClipboard helper', function () { +it('keeps clipboard logic in the shared copy button instead of a global helper', function () { $layout = file_get_contents(resource_path('views/layouts/base.blade.php')); - expect($layout) - ->toContain('async function copyToClipboard(text)') - ->toContain('window.copyToClipboard = copyToClipboard') - ->toContain('document.execCommand(\'copy\')') - ->toContain('window.isSecureContext'); + expect($layout)->not->toContain('copyToClipboard'); }); From bd6398e649dca7bf15075a14e9edc9f2840608f9 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:16:56 +0200 Subject: [PATCH 04/11] chore: remove global copyToClipboard helper --- resources/views/layouts/base.blade.php | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index a97d8c1df7..82b8cbcbdb 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -225,30 +225,6 @@ let checkHealthInterval = null; let checkIfIamDeadInterval = null; - async function copyToClipboard(text) { - try { - if (navigator.clipboard?.writeText && window.isSecureContext) { - await navigator.clipboard.writeText(text); - } else { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - const copied = document.execCommand('copy'); - document.body.removeChild(textarea); - if (!copied) { - throw new Error('Copy command was rejected.'); - } - } - window.Livewire.dispatch('success', 'Copied to clipboard.'); - } catch (error) { - window.Livewire.dispatch('error', 'Failed to copy to clipboard.'); - } - } - window.copyToClipboard = copyToClipboard; document.addEventListener('livewire:init', () => { window.Livewire.on('reloadWindow', (timeout) => { if (timeout) { From 0e35eb2aa3e62a99109127aa3818efbb8af9a20c Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:20:38 +0200 Subject: [PATCH 05/11] feat(ui): use the shared copy button component everywhere --- .../components/forms/copy-button.blade.php | 17 +------- .../components/modal-confirmation.blade.php | 13 +----- .../shared/partials/dns-copy-cell.blade.php | 41 +------------------ .../livewire/security/api-tokens.blade.php | 7 +++- .../views/livewire/team/invitations.blade.php | 9 +--- 5 files changed, 13 insertions(+), 74 deletions(-) diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php index e299610eb2..d31fac0bca 100644 --- a/resources/views/components/forms/copy-button.blade.php +++ b/resources/views/components/forms/copy-button.blade.php @@ -1,6 +1,6 @@ @props(['text', 'label' => null]) -
+
@if ($label) @endif @@ -10,19 +10,6 @@ readonly @keydown.prevent @paste.prevent @cut.prevent @drop.prevent @focus="$event.target.select()"> - +
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 0e4350f50f..f9b8bd98de 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -287,17 +287,8 @@
- +
diff --git a/resources/views/livewire/project/shared/partials/dns-copy-cell.blade.php b/resources/views/livewire/project/shared/partials/dns-copy-cell.blade.php index 7394cbd8bd..be1cbf4ca7 100644 --- a/resources/views/livewire/project/shared/partials/dns-copy-cell.blade.php +++ b/resources/views/livewire/project/shared/partials/dns-copy-cell.blade.php @@ -2,44 +2,7 @@ $break = $break ?? false; $label = $label ?? 'Copy'; @endphp -
+
$break])>{{ $text }} - +
diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 38db6aa3a6..80647458df 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -109,7 +109,12 @@ @if (session()->has('token')) - +
+ + +
@endif diff --git a/resources/views/livewire/team/invitations.blade.php b/resources/views/livewire/team/invitations.blade.php index e777ae058e..f9a3f2442e 100644 --- a/resources/views/livewire/team/invitations.blade.php +++ b/resources/views/livewire/team/invitations.blade.php @@ -29,14 +29,7 @@ {{ $invite->link }} - +
From eb9a422d9b19e850c2dcbf6869e9e68d5f60af97 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:27:57 +0200 Subject: [PATCH 07/11] refactor(ui): rename forms.copy-button to forms.copy-input --- ...-button.blade.php => copy-input.blade.php} | 0 .../views/livewire/profile/index.blade.php | 4 ++-- .../application/internal-access.blade.php | 8 ++++---- .../project/shared/resource-details.blade.php | 20 +++++++++---------- .../volume-backups/executions.blade.php | 2 +- .../project/shared/webhooks.blade.php | 6 +++--- .../server/ca-certificate/show.blade.php | 2 +- .../PersistentStorageVolumesLayoutTest.php | 2 +- .../Feature/ResourceDetailsVisibilityTest.php | 2 +- 9 files changed, 23 insertions(+), 23 deletions(-) rename resources/views/components/forms/{copy-button.blade.php => copy-input.blade.php} (100%) diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-input.blade.php similarity index 100% rename from resources/views/components/forms/copy-button.blade.php rename to resources/views/components/forms/copy-input.blade.php diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 33f1b9a98e..da5329a475 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -257,9 +257,9 @@
- - +
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php index 8ab1442ba5..6997b766b8 100644 --- a/resources/views/livewire/project/application/internal-access.blade.php +++ b/resources/views/livewire/project/application/internal-access.blade.php @@ -15,7 +15,7 @@

Internal access

@if ($currentInternalHostname) - + @else
@@ -25,9 +25,9 @@ readonly aria-live="polite">
@endif - - - + + +

diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php index 2e92c73146..1a032f6964 100644 --- a/resources/views/livewire/project/shared/resource-details.blade.php +++ b/resources/views/livewire/project/shared/resource-details.blade.php @@ -3,8 +3,8 @@

Resource

- - + +
@@ -12,8 +12,8 @@

Environment

- - + +
@endif @@ -22,8 +22,8 @@

Project

- - + +
@endif @@ -32,8 +32,8 @@

Server

- - + +
@endif @@ -43,10 +43,10 @@

Stack Sub-Resources

@foreach ($stack_applications as $item) - + @endforeach @foreach ($stack_databases as $item) - + @endforeach
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 784843f6f0..40ea7b7e09 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -71,7 +71,7 @@ - + diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index c8c42763fa..6c87e3098d 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -39,7 +39,7 @@ - + @if ($githubManualWebhook && $gitlabManualWebhook) @@ -70,7 +70,7 @@

- + @can('update', $resource) - +
@endif diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index 94d2050dc2..2279e62e39 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -34,7 +34,7 @@

Read-only bind mount

-
diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index 99133d7945..d906cfb6a1 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -184,7 +184,7 @@ it('renders volumes as a data table with shared column headers', function () { ->toMatch('/]*title="File-level consistency"[\s\S]*id="stopDuringBackup"[\s\S]*<\/x-callout>/'); expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'))) ->toContain('Time') - ->toContain('x-forms.copy-button') + ->toContain('x-forms.copy-input') ->toContain('col-span-6'); $css = file_get_contents(resource_path('css/app.css')); diff --git a/tests/Feature/ResourceDetailsVisibilityTest.php b/tests/Feature/ResourceDetailsVisibilityTest.php index 4cac570f7e..e11c86fe99 100644 --- a/tests/Feature/ResourceDetailsVisibilityTest.php +++ b/tests/Feature/ResourceDetailsVisibilityTest.php @@ -33,7 +33,7 @@ it('keeps the resource details helper text visible below the modal header', func }); it('renders copy fields as visible readonly controls with an accessible copy action', function () { - $html = Blade::render(''); + $html = Blade::render(''); expect($html) ->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"') From afbe4d6fd79ad6fbf70acc32e902ccc9932f0f0e Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:13:23 +0200 Subject: [PATCH 08/11] feat(var): add environment variable copy functionality --- .../Shared/EnvironmentVariable/Show.php | 16 ++ .../EnvironmentVariable/ShowHardcoded.php | 19 +++ app/Models/EnvironmentVariable.php | 17 ++ .../shared/environment-variable/all.blade.php | 3 +- .../EnvironmentVariableCopyValueTest.php | 151 ++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/EnvironmentVariableCopyValueTest.php diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 7f37b1fc4d..633c8f04dc 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -161,6 +161,22 @@ class Show extends Component $this->valuesLoaded = true; } + public function copyValue(): ?string + { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + return null; + } + + if (! $this->env instanceof ModelsEnvironmentVariable) { + return $this->env->value; + } + + return $this->env->get_real_environment_variables_with_server( + $this->env->resolveReferencedValue(), + $this->env->resourceable, + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index da55dee197..c2f0059399 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\EnvironmentVariable; use Livewire\Component; class ShowHardcoded extends Component @@ -20,6 +21,10 @@ class ShowHardcoded extends Component public bool $isPreview = false; + public ?string $resourceableType = null; + + public ?int $resourceableId = null; + public function mount() { $this->key = $this->env['key']; @@ -28,6 +33,20 @@ class ShowHardcoded extends Component $this->serviceName = $this->env['service_name'] ?? null; } + public function copyValue(): ?string + { + if (auth()->user()?->isMember() ?? true) { + return null; + } + + return EnvironmentVariable::make([ + 'value' => $this->value, + 'is_preview' => $this->isPreview, + 'resourceable_type' => $this->resourceableType, + 'resourceable_id' => $this->resourceableId, + ])->resolveReferencedValue(); + } + public function render() { return view('livewire.project.shared.environment-variable.show-hardcoded'); diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 89188b31b1..70c9013af2 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -302,6 +302,23 @@ class EnvironmentVariable extends BaseModel return $real_value; } + public function resolveReferencedValue(): ?string + { + $value = $this->value; + + if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) { + return $value; + } + + $referencedKey = str($value)->after('$')->trim('{}')->value(); + + return static::where('resourceable_type', $this->resourceable_type) + ->where('resourceable_id', $this->resourceable_id) + ->where('is_preview', (bool) $this->is_preview) + ->where('key', $referencedKey) + ->first()?->value ?? $value; + } + private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource); diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 923514efcc..87ecd69985 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -219,7 +219,8 @@ @else + :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" + :resourceableType="get_class($resource)" :resourceableId="$resource->id" /> @endif @endforeach
diff --git a/tests/Feature/EnvironmentVariableCopyValueTest.php b/tests/Feature/EnvironmentVariableCopyValueTest.php new file mode 100644 index 0000000000..b12105ea84 --- /dev/null +++ b/tests/Feature/EnvironmentVariableCopyValueTest.php @@ -0,0 +1,151 @@ + 0]); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create(['environment_id' => $this->environment->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +function createEnvironmentVariable(array $attributes = []): EnvironmentVariable +{ + return EnvironmentVariable::create(array_merge([ + 'key' => 'API_KEY', + 'value' => 'secret-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => test()->application->id, + ], $attributes)); +} + +function assertCopiedValue(EnvironmentVariable|SharedEnvironmentVariable $env, ?string $expected): void +{ + Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->call('copyValue') + ->assertReturned($expected); +} + +function assertCopiedComposeValue(string $value, ?string $expected): void +{ + Livewire::test(ShowHardcoded::class, [ + 'env' => ['key' => 'MYSQL_USER', 'value' => $value], + 'resourceableType' => Application::class, + 'resourceableId' => test()->application->id, + ]) + ->call('copyValue') + ->assertReturned($expected); +} + +test('copies the plain value', function () { + assertCopiedValue(createEnvironmentVariable(), 'secret-value'); +}); + +test('copies the referenced variable value instead of the reference', function (string $reference) { + createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']); + + assertCopiedValue(createEnvironmentVariable(['key' => 'MYSQL_USER', 'value' => $reference]), 'classicpress-user'); +})->with(['bare' => '$SERVICE_USER_CLASSICPRESS', 'braced' => '${SERVICE_USER_CLASSICPRESS}']); + +test('copies the resolved shared variable value', function () { + SharedEnvironmentVariable::create([ + 'key' => 'MY_SECRET', + 'value' => 'resolved-secret', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + assertCopiedValue(createEnvironmentVariable(['value' => '{{team.MY_SECRET}}']), 'resolved-secret'); +}); + +test('copies embedded, literal and unknown references as stored', function () { + createEnvironmentVariable(['key' => 'SERVICE_PASSWORD_MYSQL', 'value' => 'generated-password']); + + assertCopiedValue( + createEnvironmentVariable(['key' => 'DATABASE_URL', 'value' => 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306']), + 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306', + ); + assertCopiedValue( + createEnvironmentVariable(['key' => 'LITERAL', 'value' => '$SERVICE_PASSWORD_MYSQL', 'is_literal' => true]), + '$SERVICE_PASSWORD_MYSQL', + ); + assertCopiedValue(createEnvironmentVariable(['key' => 'UNKNOWN', 'value' => '$DOES_NOT_EXIST']), '$DOES_NOT_EXIST'); +}); + +test('copies literal values without .env-style quoting', function () { + $env = createEnvironmentVariable(['value' => 'pa$$word', 'is_literal' => true]); + + expect($env->real_value)->toBe("'pa\$\$word'"); + assertCopiedValue($env, 'pa$$word'); +}); + +test('copies the value of a shared environment variable row', function () { + $shared = SharedEnvironmentVariable::create([ + 'key' => 'TEAM_WIDE', + 'value' => 'team-wide-value', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + assertCopiedValue($shared, 'team-wide-value'); +}); + +test('members get no copy button and no value', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(Show::class, ['env' => createEnvironmentVariable(), 'type' => 'application']) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +}); + +test('locked variables get no copy button and no value', function () { + Livewire::test(Show::class, ['env' => createEnvironmentVariable(['is_shown_once' => true]), 'type' => 'application']) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +}); + +test('compose-managed rows copy the referenced variable value', function () { + createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']); + + assertCopiedComposeValue('$SERVICE_USER_CLASSICPRESS', 'classicpress-user'); + assertCopiedComposeValue('production', 'production'); +}); + +test('compose-managed rows hide copying from members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(ShowHardcoded::class, [ + 'env' => ['key' => 'MYSQL_USER', 'value' => '$SERVICE_USER_CLASSICPRESS'], + 'resourceableType' => Application::class, + 'resourceableId' => $this->application->id, + ]) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +}); From 0b811b5ef6b151844551b9c0442ed8127bd9d67e Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:58:34 +0200 Subject: [PATCH 09/11] feat(ui): add copy button to environment variable page --- .../shared/environment-variable/show-hardcoded.blade.php | 5 ++++- .../project/shared/environment-variable/show.blade.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php index 84d03c0fe8..5492d33f90 100644 --- a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -28,7 +28,10 @@ - - - -
+
+ @unless (auth()->user()?->isMember() ?? true) + + @endunless