feat(var): environment variable copy button (#11379)

This commit is contained in:
🏔️ Peak
2026-08-19 15:27:29 +02:00
committed by GitHub
30 changed files with 378 additions and 183 deletions
@@ -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) {
@@ -204,7 +220,7 @@ class Show extends Component
$this->is_required = (bool) ($this->env->is_required ?? false);
// Use the stored column, not the value-based accessor (that decrypts).
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
$this->isValueHidden = auth()->user()?->isMember() ?? false;
$this->isValueHidden = auth()->user()?->isMember() ?? true;
if ($this->valuesLoaded) {
$this->hydrateValueFields();
@@ -231,12 +247,12 @@ class Show extends Component
$this->is_really_required = $this->is_required && blank($this->value);
}
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
$this->value = null;
$this->real_value = null;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
$this->isValueHidden = auth()->user()?->isMember() ?? true;
}
public function checkEnvs()
@@ -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');
+17
View File
@@ -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);
+2
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
// Alpine data provider for the <x-copy-button> 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' });
}
},
}));
}
@@ -1,22 +1,20 @@
@props([
'value',
'value' => null,
'resolve' => null,
'label' => 'Copy to clipboard',
])
<button type="button"
x-data="{ copied: false }"
x-on:click.prevent.stop="await window.copyToClipboard({{ Js::from($value) }}); copied = true; setTimeout(() => copied = false, 1000)"
{{ $attributes->class('inline-flex size-6 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-40 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-white') }}
title="{{ $label }}" aria-label="{{ $label }}" @disabled(blank($value))>
<svg x-show="!copied" class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
aria-hidden="true">
<path d="M8 8.75H6.5A2.25 2.25 0 0 0 4.25 11v6.5a2.25 2.25 0 0 0 2.25 2.25H13a2.25 2.25 0 0 0 2.25-2.25V16"
stroke-width="1.5" stroke-linecap="round" />
<rect x="8.75" y="4.25" width="11" height="11" rx="2.25" stroke-width="1.5" />
</svg>
<svg x-show="copied" x-cloak class="size-3.5 text-green-500" viewBox="0 0 24 24" fill="none"
stroke="currentColor" aria-hidden="true">
<path d="m6.75 12.25 3.5 3.5 7-7" stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
@php
$valueExpression = $resolve ?? \Illuminate\Support\Js::from($value);
@endphp
<button type="button" title="{{ $label }}" aria-label="{{ $label }}"
{{ $attributes->class(['icon-button group shrink-0']) }} @disabled($resolve === null && blank($value))
x-data="copyButton" @click="copy(await ({{ $valueExpression }}))">
<span class="inline-flex transition-transform duration-150 ease-out group-active:scale-75">
<x-reicon name="copy" x-show="!copied" class="size-3.5" />
<x-reicon name="check" x-cloak x-show="copied" class="size-3.5 text-success"
x-transition:enter="transition-transform duration-200 ease-out"
x-transition:enter-start="scale-50" x-transition:enter-end="scale-100" />
</span>
</button>
@@ -1,28 +0,0 @@
@props(['text', 'label' => null])
<div class="w-full" x-data="{ copied: false }">
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white">{{ $label }}</label>
@endif
<div class="relative">
<input type="text" value="{{ $text }}"
class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
readonly
@keydown.prevent @paste.prevent @cut.prevent @drop.prevent
@focus="$event.target.select()">
<button
type="button"
@click.prevent="await window.copyToClipboard({{ Js::from($text) }}); copied = true; setTimeout(() => copied = false, 1000)"
class="copy-button flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 transition-colors hover:text-black focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base"
title="Copy to clipboard"
aria-label="Copy to clipboard">
<svg x-show="!copied" class="size-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg x-show="copied" class="size-[18px] text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
@@ -0,0 +1,15 @@
@props(['text', 'label' => null])
<div class="w-full">
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white">{{ $label }}</label>
@endif
<div class="relative">
<input type="text" value="{{ $text }}"
class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
readonly
@keydown.prevent @paste.prevent @cut.prevent @drop.prevent
@focus="$event.target.select()">
<x-copy-button :value="$text" class="absolute top-1/2 right-2 -translate-y-1/2" />
</div>
</div>
@@ -287,17 +287,8 @@
<div class="relative mb-2" x-data="{ decodedText: confirmationText }">
<div class="relative">
<input type="text" x-model="decodedText" readonly class="input">
<button x-show="window.isSecureContext"
@click.prevent="navigator.clipboard.writeText(decodedText); $el.innerHTML = '<svg class=\'w-5 h-5 text-green-500\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\' /></svg>'; setTimeout(() => $el.innerHTML = '<svg class=\'w-5 h-5\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\' /></svg>', 1000)"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-300 transition-colors"
title="Copy to clipboard">
<svg class="w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<x-copy-button resolve="decodedText"
class="absolute top-1/2 right-2 -translate-y-1/2" />
</div>
</div>
@@ -63,6 +63,7 @@
'upload' => '<path d="M11.4697 3.46967C11.7626 3.17678 12.2374 3.17678 12.5303 3.46967L16.5303 7.46967C16.8232 7.76256 16.8232 8.23744 16.5303 8.53033C16.2374 8.82322 15.7626 8.82322 15.4697 8.53033L12.75 5.81066V14C12.75 14.4142 12.4142 14.75 12 14.75C11.5858 14.75 11.25 14.4142 11.25 14V5.81066L8.53033 8.53033C8.23744 8.82322 7.76256 8.82322 7.46967 8.53033C7.17678 8.23744 7.17678 7.76256 7.46967 7.46967L11.4697 3.46967Z" fill="currentColor"/><path d="M4 14.25C4.41421 14.25 4.75 14.5858 4.75 15V17C4.75 18.5188 5.98122 19.75 7.5 19.75H16.5C18.0188 19.75 19.25 18.5188 19.25 17V15C19.25 14.5858 19.5858 14.25 20 14.25C20.4142 14.25 20.75 14.5858 20.75 15V17C20.75 19.3472 18.8472 21.25 16.5 21.25H7.5C5.15279 21.25 3.25 19.3472 3.25 17V15C3.25 14.5858 3.58579 14.25 4 14.25Z" fill="currentColor"/>',
'x' => '<path d="M18.4697 19.5303C18.7626 19.8232 19.2374 19.8232 19.5303 19.5303C19.8232 19.2374 19.8232 18.7626 19.5303 18.4697L13.0607 12L19.5303 5.53033C19.8232 5.23744 19.8232 4.76256 19.5303 4.46967C19.2374 4.17678 18.7626 4.17678 18.4697 4.46967L12 10.9393L5.53033 4.46967C5.23744 4.17678 4.76256 4.17678 4.46967 4.46967C4.17678 4.76256 4.17678 5.23744 4.46967 5.53033L10.9393 12L4.46967 18.4697C4.17678 18.7626 4.17678 19.2374 4.46967 19.5303C4.76256 19.8232 5.23744 19.8232 5.53033 19.5303L12 13.0607L18.4697 19.5303Z" fill="currentColor"/>',
'check' => '<path d="M21.5303 5.46967C21.8232 5.76256 21.8232 6.23744 21.5303 6.53033L9.53033 18.5303C9.23744 18.8232 8.76256 18.8232 8.46967 18.5303L2.46967 12.5303C2.17678 12.2374 2.17678 11.7626 2.46967 11.4697C2.76256 11.1768 3.23744 11.1768 3.53033 11.4697L9 16.9393L20.4697 5.46967C20.7626 5.17678 21.2374 5.17678 21.5303 5.46967Z" fill="currentColor"/>',
'copy' => '<path d="M8 8.75H6.5A2.25 2.25 0 0 0 4.25 11v6.5a2.25 2.25 0 0 0 2.25 2.25H13a2.25 2.25 0 0 0 2.25-2.25V16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="8.75" y="4.25" width="11" height="11" rx="2.25" stroke="currentColor" stroke-width="1.5"/>',
'chevron-down' => '<g transform="scale(1.33333)"><polyline points="15.25 6.5 9 12.75 2.75 6.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></polyline></g>',
'trash' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0924 1.25H8.90788C7.33861 1.24998 6.08032 1.24996 5.10577 1.38767C4.09802 1.53007 3.25979 1.83575 2.64218 2.55292C2.02457 3.27008 1.84661 4.14438 1.85528 5.1621C1.86366 6.1463 2.05033 7.39066 2.28314 8.94256L3.49937 17.0508C3.67587 18.2275 3.81878 19.1804 4.02849 19.9262C4.24683 20.7027 4.56045 21.3453 5.13662 21.8415C5.71279 22.3377 6.39485 22.5525 7.19513 22.6533C7.96377 22.75 8.92732 22.75 10.1173 22.75H13.883C15.073 22.75 16.0365 22.75 16.8052 22.6533C17.6054 22.5525 18.2875 22.3377 18.8637 21.8415C19.4398 21.3453 19.7535 20.7027 19.9718 19.9262C20.1815 19.1805 20.3244 18.2276 20.5009 17.0509L21.7172 8.94253C21.95 7.39065 22.1366 6.14629 22.145 5.1621C22.1537 4.14438 21.9757 3.27008 21.3581 2.55292C20.7405 1.83575 19.9023 1.53007 18.8945 1.38767C17.92 1.24996 16.6617 1.24998 15.0924 1.25ZM3.77879 3.53175C4.05882 3.20658 4.47927 2.9911 5.31565 2.87292C6.17295 2.75177 7.32479 2.75 8.96727 2.75H15.033C16.6755 2.75 17.8273 2.75177 18.6846 2.87292C19.521 2.9911 19.9415 3.20658 20.2215 3.53175C20.5015 3.85692 20.6523 4.30468 20.6451 5.14933C20.6448 5.18248 20.6443 5.21604 20.6435 5.25H20.5005C20.5003 5.25 20.5007 5.25 20.5005 5.25H7.00045C7.00025 5.25 7.00065 5.25 7.00045 5.25H3.35678C3.35603 5.21603 3.35551 5.18248 3.35522 5.14933C3.34803 4.30468 3.49876 3.85692 3.77879 3.53175ZM5.18949 6.75H3.48546C3.53687 7.15852 3.60161 7.61096 3.67631 8.1155L3.75015 8.18934L5.18949 6.75ZM4.05013 10.6106L4.6686 14.7338L6.37599 12.9365L4.05013 10.6106ZM5.15659 17.9593C5.17275 18.0594 5.18872 18.1563 5.20463 18.25H5.39887L5.15659 17.9593ZM6.99527 19.75C6.99879 19.75 7.00232 19.75 7.00584 19.75H13.9972C13.9991 19.75 14.0009 19.75 14.0027 19.75H18.4577C18.299 20.2287 18.1176 20.5044 17.8848 20.7049C17.6171 20.9355 17.261 21.0841 16.6178 21.165C15.9538 21.2486 15.0849 21.25 13.833 21.25H10.1673C8.91538 21.25 8.04651 21.2486 7.38247 21.165C6.73934 21.0841 6.38321 20.9355 6.11546 20.7049C5.88266 20.5044 5.70127 20.2287 5.54256 19.75H6.99527ZM15.7131 18.25H18.1895L16.9018 16.9623L15.7131 18.25ZM19.0007 16.9399C19.0087 16.8869 19.0168 16.8332 19.0249 16.7788L19.404 14.2515L17.92 15.8592L19.0007 16.9399ZM19.856 11.2381L20.2249 8.77879C20.3197 8.14673 20.4033 7.5881 20.4704 7.09045L18.16 9.40079L19.856 11.2381ZM18.6895 6.75H15.7131L17.1418 8.2977L18.6895 6.75ZM12.2532 6.75H8.81081L10.5761 8.51531L12.2532 6.75ZM11.6895 18.25H9.31081L10.5002 17.0607L11.6895 18.25ZM7.40946 11.8486L4.81081 9.25L7.00015 7.06066L9.54266 9.60317L7.40946 11.8486ZM8.47047 12.9097L10.6037 10.6642L12.6895 12.75L10.5002 14.9393L8.47047 12.9097ZM11.5608 16L13.7502 13.8107L15.8403 15.9008L13.7385 18.1777L11.5608 16ZM14.8108 12.75L16.8585 14.7977L18.9795 12.5L17.0985 10.4623L14.8108 12.75ZM13.7502 11.6893L16.0803 9.35921L13.9923 7.09721L11.6371 9.57632L13.7502 11.6893ZM7.437 13.9975L9.43949 16L7.27782 18.1617L5.50363 16.0326L7.437 13.9975Z" fill="currentColor"/>',
'external-link' => '<path d="M13 11L21.2 2.80005" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 6.8V2H17.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22H15C20 22 22 20 22 15V13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
-24
View File
@@ -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) {
@@ -257,9 +257,9 @@
</form>
<div x-data="{ showCode: false }">
<div x-cloak x-show="showCode" class="space-y-2 pb-3">
<x-forms.copy-button
<x-forms.copy-input
text="{{ decrypt(request()->user()->two_factor_secret) }}" />
<x-forms.copy-button text="{{ request()->user()->twoFactorQrCodeUrl() }}" />
<x-forms.copy-input text="{{ request()->user()->twoFactorQrCodeUrl() }}" />
</div>
<x-forms.button type="button" x-on:click="showCode = !showCode">
<span x-text="showCode ? 'Hide manual setup' : 'Show manual setup'"></span>
@@ -15,7 +15,7 @@
<h3 class="mb-4 text-sm font-semibold text-black dark:text-fg">Internal access</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
@if ($currentInternalHostname)
<x-forms.copy-button label="Internal hostname" :text="$currentInternalHostname" />
<x-forms.copy-input label="Internal hostname" :text="$currentInternalHostname" />
@else
<div class="w-full">
<label class="mb-1 flex items-center gap-1 text-sm font-medium text-black dark:text-white">Internal hostname</label>
@@ -25,9 +25,9 @@
readonly aria-live="polite">
</div>
@endif
<x-forms.copy-button label="Docker network" :text="$application->destination->network" />
<x-forms.copy-button label="Exposed ports" :text="$exposedPorts ?: 'None'" />
<x-forms.copy-button label="Network aliases" :text="$networkAliases->implode(', ') ?: 'None'" />
<x-forms.copy-input label="Docker network" :text="$application->destination->network" />
<x-forms.copy-input label="Exposed ports" :text="$exposedPorts ?: 'None'" />
<x-forms.copy-input label="Network aliases" :text="$networkAliases->implode(', ') ?: 'None'" />
</div>
<div class="mt-4 flex flex-col gap-3 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-white/[0.07]">
<p class="text-sm text-neutral-500 dark:text-fg-dim">
@@ -219,7 +219,8 @@
@else
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="{{ $row['id'] }}" :env="$row['environmentVariable']"
:isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" />
:isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType"
:resourceableType="get_class($resource)" :resourceableId="$resource->id" />
@endif
@endforeach
</div>
@@ -28,7 +28,10 @@
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<div class="justify-self-end">
<div class="flex items-center gap-0.5 justify-self-end">
@unless (auth()->user()?->isMember() ?? true)
<x-copy-button resolve="$wire.copyValue()" label="Copy value" />
@endunless
<x-modal-input title="Environment variable details" :closeOutside="false">
<x-slot:content>
<button type="button" data-env-settings-trigger class="icon-button shrink-0"
@@ -83,7 +83,10 @@
@endif
@endforeach
@endif
<div class="justify-self-end">
<div class="flex items-center gap-0.5 justify-self-end">
@if (! $isLocked && ! $isValueHidden)
<x-copy-button resolve="$wire.copyValue()" label="Copy value" />
@endif
{{-- Open modal immediately (Alpine); decrypt value in a follow-up Livewire request. --}}
<x-modal-input title="Edit environment variable" :closeOutside="false" :wireIgnore="false"
wireOpen="editorOpen">
@@ -2,44 +2,7 @@
$break = $break ?? false;
$label = $label ?? 'Copy';
@endphp
<div class="flex min-w-0 items-center gap-1.5"
x-data="{
copied: false,
async copy(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', '');
el.style.position = 'fixed';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
this.copied = true;
setTimeout(() => this.copied = false, 1000);
} catch (e) {
console.error('Copy failed', e);
}
}
}">
<div class="flex min-w-0 items-center gap-1.5">
<span @class(['min-w-0', 'break-all' => $break])>{{ $text }}</span>
<button type="button"
@click.prevent.stop="copy(@js($text))"
class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-coolgray-200 dark:hover:text-white"
title="{{ $label }}"
aria-label="{{ $label }}">
<svg x-show="!copied" class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg x-show="copied" x-cloak class="size-3.5 text-green-500" fill="none" stroke="currentColor"
viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
<x-copy-button :value="$text" :label="$label" />
</div>
@@ -3,8 +3,8 @@
<div>
<h3>Resource</h3>
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
<x-forms.copy-button label="Name" :text="$resource->name ?? ''" />
<x-forms.copy-button label="UUID" :text="$resource->uuid ?? ''" />
<x-forms.copy-input label="Name" :text="$resource->name ?? ''" />
<x-forms.copy-input label="UUID" :text="$resource->uuid ?? ''" />
</div>
</div>
@@ -12,8 +12,8 @@
<div>
<h3>Environment</h3>
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
<x-forms.copy-button label="Name" :text="$environment_name ?? ''" />
<x-forms.copy-button label="UUID" :text="$environment_uuid" />
<x-forms.copy-input label="Name" :text="$environment_name ?? ''" />
<x-forms.copy-input label="UUID" :text="$environment_uuid" />
</div>
</div>
@endif
@@ -22,8 +22,8 @@
<div>
<h3>Project</h3>
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
<x-forms.copy-button label="Name" :text="$project_name ?? ''" />
<x-forms.copy-button label="UUID" :text="$project_uuid" />
<x-forms.copy-input label="Name" :text="$project_name ?? ''" />
<x-forms.copy-input label="UUID" :text="$project_uuid" />
</div>
</div>
@endif
@@ -32,8 +32,8 @@
<div>
<h3>Server</h3>
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
<x-forms.copy-button label="Name" :text="$server_name ?? ''" />
<x-forms.copy-button label="UUID" :text="$server_uuid" />
<x-forms.copy-input label="Name" :text="$server_name ?? ''" />
<x-forms.copy-input label="UUID" :text="$server_uuid" />
</div>
</div>
@endif
@@ -43,10 +43,10 @@
<h3>Stack Sub-Resources</h3>
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
@foreach ($stack_applications as $item)
<x-forms.copy-button :label="'Application: ' . $item['name']" :text="$item['uuid']" />
<x-forms.copy-input :label="'Application: ' . $item['name']" :text="$item['uuid']" />
@endforeach
@foreach ($stack_databases as $item)
<x-forms.copy-button :label="'Database: ' . $item['name']" :text="$item['uuid']" />
<x-forms.copy-input :label="'Database: ' . $item['name']" :text="$item['uuid']" />
@endforeach
</div>
</div>
@@ -71,7 +71,7 @@
</span>
<span class="min-w-0">
<x-forms.copy-button :text="$execution->filename ?? 'No archive name'" />
<x-forms.copy-input :text="$execution->filename ?? 'No archive name'" />
</span>
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">
@@ -39,7 +39,7 @@
<x-external-link />
</a>
</x-slot:actions>
<x-forms.copy-button label="Deploy webhook URL" :text="$deploywebhook ?? ''" />
<x-forms.copy-input label="Deploy webhook URL" :text="$deploywebhook ?? ''" />
</x-application.settings-section>
@if ($githubManualWebhook && $gitlabManualWebhook)
@@ -70,7 +70,7 @@
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<x-forms.copy-button label="Webhook URL" :text="$provider['url'] ?? ''" />
<x-forms.copy-input label="Webhook URL" :text="$provider['url'] ?? ''" />
@can('update', $resource)
<x-forms.input type="password" :id="$provider['secret']"
label="Webhook secret"
@@ -106,7 +106,7 @@
<x-external-link />
</a>
</x-slot:actions>
<x-forms.copy-button label="Deploy webhook URL" :text="$deploywebhook ?? ''" />
<x-forms.copy-input label="Deploy webhook URL" :text="$deploywebhook ?? ''" />
</x-application.settings-section>
</div>
@endif
@@ -109,7 +109,12 @@
@if (session()->has('token'))
<x-application.settings-section title="Copy your token"
description="This value will not be shown again after you leave this page.">
<x-forms.copy-button :text="session('token')" />
<div class="relative">
<input type="text" value="{{ session('token') }}" readonly
class="input w-full pr-12! font-mono text-[12px] text-black dark:text-fg">
<x-copy-button :value="session('token')" label="Copy token"
class="absolute top-1/2 right-2 -translate-y-1/2" />
</div>
</x-application.settings-section>
@endif
@@ -34,7 +34,7 @@
<div class="mt-4">
<p class="mb-1.5 text-xs font-medium text-neutral-500 dark:text-fg-dim">Read-only bind mount</p>
<x-forms.copy-button
<x-forms.copy-input
text="- /data/coolify/ssl/coolify-ca.crt:/etc/ssl/certs/coolify-ca.crt:ro" />
</div>
</x-application.settings-section>
@@ -29,14 +29,7 @@
<span
class="min-w-0 truncate font-mono text-[12px] text-neutral-500 dark:text-fg-dim"
title="{{ $invite->link }}">{{ $invite->link }}</span>
<button type="button"
class="button h-7! shrink-0 px-2!"
title="Copy invitation link"
aria-label="Copy invitation link"
x-data
x-on:click.prevent="window.copyToClipboard(@js($invite->link))">
<x-reicon name="file-content" class="size-3.5" />
</button>
<x-copy-button :value="$invite->link" label="Copy invitation link" />
</div>
<div class="text-right">
<button type="button"
@@ -18,7 +18,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
@@ -103,6 +103,11 @@ test('admin sees unlocked env value in Show component', function () {
'type' => 'application',
]);
// Values hydrate lazily; the edit modal triggers loadValues() on open.
expect($component->get('value'))->toBeNull();
$component->call('loadValues');
expect($component->get('value'))->toBe('secret-unlocked-value');
});
@@ -246,6 +251,17 @@ test('API hides env values for member even with read:sensitive token', function
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertForbidden();
});
test('API hides env values for member with read token', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('member-read', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
+32 -6
View File
@@ -1,16 +1,42 @@
<?php
it('renders a reusable compact copy button', function () {
it('renders a self-contained clipboard button for backend-provided values', function () {
$html = $this->blade('<x-copy-button value="backup/path.sql" label="Copy backup path" />');
$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('<x-copy-button :value="null" />');
expect($view)->toContain('<x-copy-button :value="data_get($execution, \'filename\', \'\')" label="Copy backup path" />');
$html->assertSee('disabled', false);
});
it('evaluates a resolve expression at click time instead of a static value', function () {
$html = $this->blade('<x-copy-button resolve="$wire.copyValue()" />');
$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('<x-copy-button resolve="decodedText"')
->not->toContain('navigator.clipboard');
expect($backupExecutions)
->toContain('<x-copy-button :value="data_get($execution, \'filename\', \'\')" label="Copy backup path"')
->not->toContain('navigator.clipboard');
});
@@ -71,7 +71,7 @@ it('loads environment variables when loadEnvironmentVariables is called', functi
->assertSee('Loading environment variables...')
->call('loadEnvironmentVariables')
->assertSet('readyToLoad', true)
->assertDontSee('Loading environment variables...')
->assertDontSeeText('Loading environment variables...')
->assertSee('API_KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
@@ -0,0 +1,151 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Livewire\Project\Shared\EnvironmentVariable\ShowHardcoded;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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);
});
@@ -184,7 +184,7 @@ it('renders volumes as a data table with shared column headers', function () {
->toMatch('/<x-callout[^>]*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('<span>Time</span>')
->toContain('x-forms.copy-button')
->toContain('x-forms.copy-input')
->toContain('col-span-6');
$css = file_get_contents(resource_path('css/app.css'));
@@ -27,31 +27,29 @@ 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');
});
it('renders copy fields as visible readonly controls with an accessible copy action', function () {
$html = Blade::render('<x-forms.copy-button label="UUID" text="crashloop" />');
$html = Blade::render('<x-forms.copy-input label="UUID" text="crashloop" />');
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('<x-forms.copy-button :text="session(\'token\')" />')
->not->toContain('navigator.clipboard.writeText(@js(session(\'token\')))');
->toContain('<x-copy-button :value="session(\'token\')"')
->not->toContain('navigator.clipboard');
});
it('keeps copy button padding above settings-workspace input overrides', function () {
+4 -10
View File
@@ -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('<x-copy-button :value="$invite->link" label="Copy invitation link" />');
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');
});