diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 5f5e12e0a..fa65e8bd2 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,8 +2,11 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Environment; +use App\Models\Project; use App\Traits\EnvironmentVariableAnalyzer; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Computed; use Livewire\Component; class Add extends Component @@ -56,6 +59,72 @@ public function mount() $this->problematicVariables = self::getProblematicVariablesForFrontend(); } + #[Computed] + public function availableSharedVariables(): array + { + $team = currentTeam(); + $result = [ + 'team' => [], + 'project' => [], + 'environment' => [], + ]; + + // Early return if no team + if (! $team) { + return $result; + } + + // Check if user can view team variables + try { + $this->authorize('view', $team); + $result['team'] = $team->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view team variables + } + + // Get project variables if we have a project_uuid in route + $projectUuid = data_get($this->parameters, 'project_uuid'); + if ($projectUuid) { + $project = Project::where('team_id', $team->id) + ->where('uuid', $projectUuid) + ->first(); + + if ($project) { + try { + $this->authorize('view', $project); + $result['project'] = $project->environment_variables() + ->pluck('key') + ->toArray(); + + // Get environment variables if we have an environment_uuid in route + $environmentUuid = data_get($this->parameters, 'environment_uuid'); + if ($environmentUuid) { + $environment = $project->environments() + ->where('uuid', $environmentUuid) + ->first(); + + if ($environment) { + try { + $this->authorize('view', $environment); + $result['environment'] = $environment->environment_variables() + ->pluck('key') + ->toArray(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view environment variables + } + } + } + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User not authorized to view project variables + } + } + } + + return $result; + } + public function submit() { $this->validate(); diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php new file mode 100644 index 000000000..7cf8ee8fa --- /dev/null +++ b/app/View/Components/Forms/EnvVarInput.php @@ -0,0 +1,89 @@ +canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + } + } + } + + public function render(): View|Closure|string + { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + + if (is_null($this->id)) { + $this->id = new Cuid2; + // Don't create wire:model binding for auto-generated IDs + $this->modelBinding = 'null'; + } + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->modelBinding && $this->modelBinding !== 'null') { + // Use original ID with random suffix for uniqueness + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; + } else { + $this->htmlId = (string) $this->id; + } + + if (is_null($this->name)) { + $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; + } + + $this->scopeUrls = [ + 'team' => route('shared-variables.team.index'), + 'project' => route('shared-variables.project.index'), + 'environment' => $this->projectUuid && $this->environmentUuid + ? route('shared-variables.environment.show', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + ]) + : route('shared-variables.environment.index'), + 'default' => route('shared-variables.index'), + ]; + + return view('components.forms.env-var-input'); + } +} diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php new file mode 100644 index 000000000..0859db78d --- /dev/null +++ b/resources/views/components/forms/env-var-input.blade.php @@ -0,0 +1,268 @@ +
+ @if ($label) + + @endif + +
+ + merge(['class' => $defaultClass]) }} + @required($required) + @readonly($readonly) + @if ($modelBinding !== 'null') + wire:model="{{ $modelBinding }}" + wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" + @endif + wire:loading.attr="disabled" + type="{{ $type }}" + @disabled($disabled) + @if ($htmlId !== 'null') id={{ $htmlId }} @endif + name="{{ $name }}" + placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) autofocus @endif> + + {{-- Dropdown for suggestions --}} +
+ + + +
+ +
+
+
+ + @if (!$label && $helper) + + @endif + @error($modelBinding) + + @enderror +
diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php index 353fe02de..9bc4f06a3 100644 --- a/resources/views/livewire/project/shared/environment-variable/add.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php @@ -1,8 +1,20 @@
- - + @if ($is_multiline) + + @else + + @endif + + @if (!$shared && !$is_multiline) +
+ Tip: Type {{ to reference a shared environment + variable +
+ @endif @if (!$shared) Save - + \ No newline at end of file diff --git a/tests/Unit/EnvVarInputComponentTest.php b/tests/Unit/EnvVarInputComponentTest.php new file mode 100644 index 000000000..f4fc8bcb5 --- /dev/null +++ b/tests/Unit/EnvVarInputComponentTest.php @@ -0,0 +1,67 @@ +required)->toBeFalse() + ->and($component->disabled)->toBeFalse() + ->and($component->readonly)->toBeFalse() + ->and($component->defaultClass)->toBe('input') + ->and($component->availableVars)->toBe([]); +}); + +it('uses provided id', function () { + $component = new EnvVarInput(id: 'env-key'); + + expect($component->id)->toBe('env-key'); +}); + +it('accepts available vars', function () { + $vars = [ + 'team' => ['DATABASE_URL', 'API_KEY'], + 'project' => ['STRIPE_KEY'], + 'environment' => ['DEBUG'], + ]; + + $component = new EnvVarInput(availableVars: $vars); + + expect($component->availableVars)->toBe($vars); +}); + +it('accepts required parameter', function () { + $component = new EnvVarInput(required: true); + + expect($component->required)->toBeTrue(); +}); + +it('accepts disabled state', function () { + $component = new EnvVarInput(disabled: true); + + expect($component->disabled)->toBeTrue(); +}); + +it('accepts readonly state', function () { + $component = new EnvVarInput(readonly: true); + + expect($component->readonly)->toBeTrue(); +}); + +it('accepts autofocus parameter', function () { + $component = new EnvVarInput(autofocus: true); + + expect($component->autofocus)->toBeTrue(); +}); + +it('accepts authorization properties', function () { + $component = new EnvVarInput( + canGate: 'update', + canResource: 'resource', + autoDisable: false + ); + + expect($component->canGate)->toBe('update') + ->and($component->canResource)->toBe('resource') + ->and($component->autoDisable)->toBeFalse(); +}); diff --git a/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php b/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php new file mode 100644 index 000000000..19da8b43b --- /dev/null +++ b/tests/Unit/Livewire/EnvironmentVariableAutocompleteTest.php @@ -0,0 +1,53 @@ +toBeTrue(); +}); + +it('component has required properties for environment variable autocomplete', function () { + $component = new Add; + + expect($component)->toHaveProperty('key') + ->and($component)->toHaveProperty('value') + ->and($component)->toHaveProperty('is_multiline') + ->and($component)->toHaveProperty('is_literal') + ->and($component)->toHaveProperty('is_runtime') + ->and($component)->toHaveProperty('is_buildtime') + ->and($component)->toHaveProperty('parameters'); +}); + +it('returns empty arrays when currentTeam returns null', function () { + // Mock Auth facade to return null for user + Auth::shouldReceive('user') + ->andReturn(null); + + $component = new Add; + $component->parameters = []; + + $result = $component->availableSharedVariables(); + + expect($result)->toBe([ + 'team' => [], + 'project' => [], + 'environment' => [], + ]); +}); + +it('availableSharedVariables method wraps authorization checks in try-catch blocks', function () { + // Read the source code to verify the authorization pattern + $reflectionMethod = new ReflectionMethod(Add::class, 'availableSharedVariables'); + $source = file_get_contents($reflectionMethod->getFileName()); + + // Verify that the method contains authorization checks + expect($source)->toContain('$this->authorize(\'view\', $team)') + ->and($source)->toContain('$this->authorize(\'view\', $project)') + ->and($source)->toContain('$this->authorize(\'view\', $environment)') + // Verify authorization checks are wrapped in try-catch blocks + ->and($source)->toContain('} catch (\Illuminate\Auth\Access\AuthorizationException $e) {'); +});