Menu

Change language

GitHub repo

Environment Variables Manager

blockdevops

Vercel and Supabase style environment variables and secrets manager with environment scoping, mask/reveal toggles, bulk import, and inline editing.

Installation

$npx uipkge-ng@latest add environment-variables-manager
Manifest:https://uipkge.dev/r/angular/environment-variables-manager.jsonInstalls to:src/app/components/blocks/environment-variables-manager/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

EnvironmentVariable
interface EnvironmentVariable {
  id: string
  key: string
  value: string
  environments: EnvironmentScope[]
  isSecret: boolean
  updatedAt: string
  updatedBy: string
}
ScopeOption
interface ScopeOption {
  value: EnvironmentScope
  label: string
  /** Label border/background when the scope is checked. */
  active: string
  /** Label text colour. */
  text: string
}
ParsedImportItem
interface ParsedImportItem {
  key: string
  value: string
  isSecret: boolean
}

Files installed (2)

  • src/app/components/blocks/environment-variables-manager/environment-variables-manager.component.ts41.1 kB
    // Vercel / Supabase style environment variables manager: environment-scoped tabs with counts,
    // search + type filter, mask/reveal and copy per value, an inline "add variable" card, an edit
    // dialog, bulk .env import with a live parse preview, and .env export. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      AlertCircle,
      Check,
      Copy,
      Download,
      Eye,
      EyeOff,
      FileCode,
      KeyRound,
      Lock,
      LucideAngularModule,
      MoreHorizontal,
      Pencil,
      Plus,
      Search,
      ShieldCheck,
      Trash2,
      Upload,
      X,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiCheckboxComponent } from '@/ui/checkbox/checkbox.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    import { UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent } from '@/ui/tabs/tabs.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export type EnvironmentScope = 'production' | 'preview' | 'development'
    export type EnvVarTypeFilter = 'all' | 'secret' | 'plain'
    
    export interface EnvironmentVariable {
      id: string
      key: string
      value: string
      environments: EnvironmentScope[]
      isSecret: boolean
      updatedAt: string
      updatedBy: string
    }
    
    const defaultVariables: EnvironmentVariable[] = [
      {
        id: 'env-1',
        key: 'DATABASE_URL',
        value: 'postgresql://postgres:[email protected]:5432/primary_db',
        environments: ['production', 'preview', 'development'],
        isSecret: true,
        updatedAt: 'Updated 2h ago',
        updatedBy: 'alex.chen',
      },
      {
        id: 'env-2',
        key: 'STRIPE_SECRET_KEY',
        value: 'mock_key_51NwY2xK9mPqL8vR4tZa0bCeFgHiJkLmNoPqRsTuVwXyZ',
        environments: ['production'],
        isSecret: true,
        updatedAt: 'Updated 3d ago',
        updatedBy: 'sarah.dev',
      },
      {
        id: 'env-3',
        key: 'NEXT_PUBLIC_APP_URL',
        value: 'https://app.uipkge.dev',
        environments: ['production', 'preview', 'development'],
        isSecret: false,
        updatedAt: 'Updated 5d ago',
        updatedBy: 'uday.craft',
      },
      {
        id: 'env-4',
        key: 'REDIS_PASSWORD',
        value: 'redis_auth_98f4b6201e9d4a87b32c',
        environments: ['production', 'preview'],
        isSecret: true,
        updatedAt: 'Updated 1w ago',
        updatedBy: 'alex.chen',
      },
      {
        id: 'env-5',
        key: 'SENTRY_DSN',
        value: 'https://[email protected]/45089234871923',
        environments: ['production', 'preview', 'development'],
        isSecret: false,
        updatedAt: 'Updated 2w ago',
        updatedBy: 'sarah.dev',
      },
      {
        id: 'env-6',
        key: 'AWS_SECRET_ACCESS_KEY',
        value: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
        environments: ['production'],
        isSecret: true,
        updatedAt: 'Updated 1mo ago',
        updatedBy: 'infra-bot',
      },
    ]
    
    function maskString(val: string): string {
      if (val.length <= 12) return '••••••••••••'
      return '••••••••••••••••••••'
    }
    
    const ALL_ENVS: EnvironmentScope[] = ['production', 'preview', 'development']
    
    interface ScopeOption {
      value: EnvironmentScope
      label: string
      /** Label border/background when the scope is checked. */
      active: string
      /** Label text colour. */
      text: string
    }
    
    const scopeOptions: ScopeOption[] = [
      { value: 'production', label: 'Production', active: 'border-success/40 bg-success/5', text: 'text-success' },
      { value: 'preview', label: 'Preview', active: 'border-info/40 bg-info/5', text: 'text-info' },
      { value: 'development', label: 'Development', active: 'border-chart-2/40 bg-chart-2/5', text: 'text-chart-2' },
    ]
    
    interface ParsedImportItem {
      key: string
      value: string
      isSecret: boolean
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-environment-variables-manager, [ui-environment-variables-manager]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiCheckboxComponent,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiTableComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
        UiTabsComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="environment-variables-manager" [class]="rootClass">
          <!-- Header Section -->
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1">
              <div class="flex items-center gap-2.5">
                <div class="bg-primary/10 text-primary grid size-8 place-items-center rounded-lg border">
                  <lucide-icon [img]="KeyRound" class="size-4" />
                </div>
                <h2 class="text-xl font-semibold tracking-tight sm:text-2xl">Environment Variables</h2>
              </div>
              <p class="text-muted-foreground text-sm">
                Manage encrypted secrets, API keys, and configuration for your deployments.
              </p>
            </div>
    
            <div class="flex flex-wrap items-center gap-2">
              <button ui-button variant="outline" size="sm" class="gap-1.5 shadow-xs" (click)="isImportOpen.set(true)">
                <lucide-icon [img]="Upload" class="size-3.5" />
                <span>Import .env</span>
              </button>
              <button
                ui-button
                aria-label="Download attachment"
                variant="outline"
                size="sm"
                class="gap-1.5 shadow-xs"
                (click)="exportEnvFile()"
              >
                <lucide-icon [img]="Download" class="size-3.5" />
                <span>Export</span>
              </button>
              <button
                ui-button
                size="sm"
                class="gap-1.5 shadow-xs"
                [variant]="isAddOpen() ? 'secondary' : 'default'"
                (click)="isAddOpen.set(!isAddOpen())"
              >
                @if (!isAddOpen()) {
                  <lucide-icon [img]="Plus" class="size-4" />
                } @else {
                  <lucide-icon [img]="X" class="size-4" />
                }
                <span>{{ isAddOpen() ? 'Close Form' : 'Add Variable' }}</span>
              </button>
            </div>
          </div>
    
          <!-- Add Variable Expandable Card -->
          @if (isAddOpen()) {
            <div ui-card class="border-primary/30 bg-card/60 relative shadow-sm">
              <div ui-card-header class="pb-3">
                <div class="flex items-center justify-between">
                  <div>
                    <h3 ui-card-title class="text-base font-semibold">New Environment Variable</h3>
                    <p ui-card-description class="text-xs">
                      Add a new secret key or configuration parameter to your environment scopes.
                    </p>
                  </div>
                  <button
                    ui-button
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Cancel adding variable"
                    (click)="resetAddForm()"
                  >
                    <lucide-icon [img]="X" class="size-4" />
                  </button>
                </div>
              </div>
              <div ui-card-content class="space-y-4">
                <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">Variable Key</label>
                    <ui-input
                      [value]="newKey()"
                      (valueChange)="newKey.set($event)"
                      placeholder="e.g. STRIPE_SECRET_KEY"
                      class="font-mono text-xs uppercase"
                      (keydown.enter)="handleAddVariable()"
                    />
                    <p class="text-muted-foreground text-xs">Uppercase characters and underscores recommended.</p>
                  </div>
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">Variable Value</label>
                    <ui-input
                      [value]="newValue()"
                      (valueChange)="newValue.set($event)"
                      [type]="newIsSecret() ? 'password' : 'text'"
                      placeholder="Enter secret token or value..."
                      class="font-mono text-xs"
                      (keydown.enter)="handleAddVariable()"
                    />
                    <p class="text-muted-foreground text-xs">Values are encrypted at rest with AES-256-GCM.</p>
                  </div>
                </div>
    
                <div class="flex flex-col gap-4 pt-1 sm:flex-row sm:items-center sm:justify-between">
                  <div class="space-y-2">
                    <span class="text-foreground text-xs font-medium">Environment Scopes</span>
                    <div class="flex flex-wrap items-center gap-3">
                      @for (scope of scopeOptions; track scope.value) {
                        <label [class]="scopeLabelClass(newEnvs(), scope, 'gap-2 transition-colors')">
                          <ui-checkbox
                            [checked]="newEnvs().includes(scope.value)"
                            (checkedChange)="toggleNewEnv(scope.value)"
                          />
                          <span [class]="scope.text + ' font-medium'">{{ scope.label }}</span>
                        </label>
                      }
                    </div>
                  </div>
    
                  <div class="flex items-center gap-2">
                    <ui-checkbox
                      id="angular-new-is-secret"
                      [checked]="newIsSecret()"
                      (checkedChange)="newIsSecret.set(!!$event)"
                    />
                    <label for="angular-new-is-secret" class="cursor-pointer text-xs font-medium select-none">
                      Encrypt as Secret
                    </label>
                  </div>
                </div>
    
                <div class="flex items-center justify-end gap-2 border-t pt-3">
                  <button ui-button aria-label="Cancel adding variable" variant="ghost" size="sm" (click)="resetAddForm()">
                    Cancel
                  </button>
                  <button
                    ui-button
                    size="sm"
                    [disabled]="!newKey().trim() || !newValue().trim()"
                    (click)="handleAddVariable()"
                  >
                    Save Variable
                  </button>
                </div>
              </div>
            </div>
          }
    
          <!-- Environment Tabs & Search / Filter Controls -->
          <div class="space-y-3">
            <ui-tabs [value]="activeTab()" (valueChange)="setActiveTab($event)" class="w-full">
              <ui-tabs-list class="w-full justify-start overflow-x-auto sm:w-auto">
                <button ui-tabs-trigger value="all" class="text-xs">
                  All Environments
                  <span ui-badge variant="secondary" class="ml-1.5 px-1.5 py-0 text-xs font-semibold">
                    {{ counts().all }}
                  </span>
                </button>
                <button ui-tabs-trigger value="production" class="text-xs">
                  Production
                  <span ui-badge variant="secondary" class="ml-1.5 px-1.5 py-0 text-xs font-semibold">
                    {{ counts().production }}
                  </span>
                </button>
                <button ui-tabs-trigger value="preview" class="text-xs">
                  Preview
                  <span ui-badge variant="secondary" class="ml-1.5 px-1.5 py-0 text-xs font-semibold">
                    {{ counts().preview }}
                  </span>
                </button>
                <button ui-tabs-trigger value="development" class="text-xs">
                  Development
                  <span ui-badge variant="secondary" class="ml-1.5 px-1.5 py-0 text-xs font-semibold">
                    {{ counts().development }}
                  </span>
                </button>
              </ui-tabs-list>
            </ui-tabs>
    
            <div class="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between">
              <div class="flex flex-1 flex-wrap items-center gap-2">
                <div class="relative max-w-sm min-w-[200px] flex-1">
                  <lucide-icon
                    [img]="Search"
                    class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                  />
                  <ui-input
                    [value]="searchQuery()"
                    (valueChange)="searchQuery.set($event)"
                    placeholder="Search variables by key..."
                    class="h-8.5 pl-8 text-xs shadow-xs"
                  />
                </div>
    
                <ui-select [value]="typeFilter()" (valueChange)="setTypeFilter($event)">
                  <button ui-select-trigger class="h-8.5 w-[140px] text-xs shadow-xs">
                    <ui-select-value placeholder="All types" />
                  </button>
                  <ui-select-content>
                    <ui-select-item value="all">All Types</ui-select-item>
                    <ui-select-item value="secret">Encrypted Secrets</ui-select-item>
                    <ui-select-item value="plain">Plaintext</ui-select-item>
                  </ui-select-content>
                </ui-select>
              </div>
    
              <div class="flex items-center gap-2">
                <button ui-button variant="outline" size="sm" [class]="revealAllClass" (click)="toggleRevealAll()">
                  @if (revealAll()) {
                    <lucide-icon [img]="EyeOff" class="size-3.5" />
                  } @else {
                    <lucide-icon [img]="Eye" class="size-3.5" />
                  }
                  <span>{{ revealAll() ? 'Hide All' : 'Reveal All Values' }}</span>
                </button>
              </div>
            </div>
          </div>
    
          <!-- Variables Table -->
          <div class="bg-card overflow-hidden rounded-lg border shadow-xs">
            <div class="overflow-x-auto">
              <table ui-table>
                <thead ui-table-header>
                  <tr ui-table-row class="bg-muted/40 hover:bg-muted/40">
                    <th ui-table-head class="text-muted-foreground text-xs font-medium">Variable Key</th>
                    <th ui-table-head class="text-muted-foreground text-xs font-medium">Value</th>
                    <th ui-table-head class="text-muted-foreground text-xs font-medium">Environments</th>
                    <th ui-table-head class="text-muted-foreground text-xs font-medium">Type</th>
                    <th ui-table-head class="text-muted-foreground hidden text-xs font-medium md:table-cell">Updated</th>
                    <th ui-table-head class="w-[70px] text-right text-xs font-medium">
                      <span class="sr-only">Actions</span>
                    </th>
                  </tr>
                </thead>
                <tbody ui-table-body>
                  @for (item of filteredVariables(); track item.id) {
                    <tr ui-table-row class="group hover:bg-muted/30 transition-colors">
                      <!-- Key -->
                      <td ui-table-cell class="py-3.5">
                        <div class="flex items-center gap-2">
                          <span
                            class="text-foreground font-mono text-xs font-semibold tracking-tight select-all sm:text-sm"
                          >
                            {{ item.key }}
                          </span>
                          <button
                            type="button"
                            aria-label="Copy variable key"
                            class="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm opacity-0 transition-opacity outline-none group-hover:opacity-100 focus:opacity-100 focus-visible:ring-2"
                            (click)="copyValue(item.key, 'key-' + item.id)"
                          >
                            @if (copiedId() === 'key-' + item.id) {
                              <lucide-icon [img]="Check" class="text-success size-3.5" />
                            } @else {
                              <lucide-icon [img]="Copy" class="size-3.5" />
                            }
                          </button>
                        </div>
                      </td>
    
                      <!-- Value Mask / Plain -->
                      <td ui-table-cell class="py-3.5">
                        <div class="flex max-w-[280px] items-center gap-1.5 sm:max-w-xs md:max-w-sm">
                          <code
                            class="bg-muted/60 text-foreground max-w-[200px] min-w-0 truncate rounded border px-2 py-1 font-mono text-xs sm:max-w-[240px]"
                            [attr.title]="isRevealed(item.id) ? item.value : 'Masked secret value'"
                            >{{ isRevealed(item.id) ? item.value : mask(item.value) }}</code
                          >
                          <button
                            ui-button
                            variant="ghost"
                            size="icon-xs"
                            class="text-muted-foreground hover:text-foreground size-7 shrink-0"
                            [attr.aria-label]="isRevealed(item.id) ? 'Hide value' : 'Reveal value'"
                            (click)="toggleReveal(item.id)"
                          >
                            @if (isRevealed(item.id)) {
                              <lucide-icon [img]="EyeOff" class="size-3.5" />
                            } @else {
                              <lucide-icon [img]="Eye" class="size-3.5" />
                            }
                          </button>
                          <button
                            ui-button
                            variant="ghost"
                            size="icon-xs"
                            class="text-muted-foreground hover:text-foreground size-7 shrink-0"
                            [attr.aria-label]="copiedId() === item.id ? 'Copied' : 'Copy value'"
                            (click)="copyValue(item.value, item.id)"
                          >
                            @if (copiedId() === item.id) {
                              <lucide-icon [img]="Check" class="text-success size-3.5" />
                            } @else {
                              <lucide-icon [img]="Copy" class="size-3.5" />
                            }
                          </button>
                        </div>
                      </td>
    
                      <!-- Environments -->
                      <td ui-table-cell class="py-3.5">
                        <div class="flex flex-wrap items-center gap-1">
                          @if (item.environments.includes('production')) {
                            <span
                              ui-badge
                              variant="outline"
                              class="border-success/30 bg-success/10 text-success px-2 py-0 text-xs font-medium"
                            >
                              Production
                            </span>
                          }
                          @if (item.environments.includes('preview')) {
                            <span
                              ui-badge
                              variant="outline"
                              class="border-info/30 bg-info/10 text-info px-2 py-0 text-xs font-medium"
                            >
                              Preview
                            </span>
                          }
                          @if (item.environments.includes('development')) {
                            <span
                              ui-badge
                              variant="outline"
                              class="border-chart-2/30 bg-chart-2/10 text-chart-2 px-2 py-0 text-xs font-medium"
                            >
                              Development
                            </span>
                          }
                        </div>
                      </td>
    
                      <!-- Type -->
                      <td ui-table-cell class="py-3.5">
                        @if (item.isSecret) {
                          <span
                            ui-badge
                            variant="secondary"
                            class="border-warning/30 bg-warning/10 text-warning gap-1 px-2 py-0 text-xs font-medium"
                          >
                            <lucide-icon [img]="Lock" class="size-3" />
                            Encrypted Secret
                          </span>
                        } @else {
                          <span
                            ui-badge
                            variant="outline"
                            class="text-muted-foreground gap-1 px-2 py-0 text-xs font-medium"
                          >
                            <lucide-icon [img]="FileCode" class="size-3" />
                            Plaintext
                          </span>
                        }
                      </td>
    
                      <!-- Updated -->
                      <td ui-table-cell class="text-muted-foreground hidden py-3.5 text-xs whitespace-nowrap md:table-cell">
                        <span>{{ item.updatedAt }}</span>
                        <span class="block text-xs opacity-75">by {{ item.updatedBy }}</span>
                      </td>
    
                      <!-- Actions Dropdown -->
                      <td ui-table-cell class="py-3.5 text-right">
                        <ui-dropdown-menu>
                          <button
                            ui-button
                            ui-dropdown-menu-trigger
                            variant="ghost"
                            size="icon-sm"
                            class="text-muted-foreground hover:text-foreground size-8"
                          >
                            <lucide-icon [img]="MoreHorizontal" class="size-4" />
                            <span class="sr-only">Actions for {{ item.key }}</span>
                          </button>
                          <ui-dropdown-menu-content align="end" class="w-44 text-xs">
                            <ui-dropdown-menu-item (select)="openEdit(item)">
                              <lucide-icon [img]="Pencil" class="mr-2 size-3.5" />
                              <span>Edit variable</span>
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-item (select)="handleDuplicate(item)">
                              <lucide-icon [img]="Copy" class="mr-2 size-3.5" />
                              <span>Duplicate</span>
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-item (select)="copyValue(item.value, item.id)">
                              @if (copiedId() === item.id) {
                                <lucide-icon [img]="Check" class="text-success mr-2 size-3.5" />
                              } @else {
                                <lucide-icon [img]="Copy" class="mr-2 size-3.5" />
                              }
                              <span>Copy value</span>
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-separator />
                            <ui-dropdown-menu-item
                              class="text-destructive focus:text-destructive"
                              (select)="handleDelete(item.id)"
                            >
                              <lucide-icon [img]="Trash2" class="mr-2 size-3.5" />
                              <span>Delete</span>
                            </ui-dropdown-menu-item>
                          </ui-dropdown-menu-content>
                        </ui-dropdown-menu>
                      </td>
                    </tr>
                  }
    
                  <!-- Empty State -->
                  @if (filteredVariables().length === 0) {
                    <tr ui-table-row>
                      <td ui-table-cell [attr.colspan]="6" class="h-44 text-center">
                        <div class="flex flex-col items-center justify-center space-y-2 py-6">
                          <div class="bg-muted text-muted-foreground grid size-10 place-items-center rounded-full border">
                            <lucide-icon [img]="AlertCircle" class="size-5" />
                          </div>
                          <p class="text-foreground text-sm font-medium">No environment variables found</p>
                          <p class="text-muted-foreground max-w-xs text-xs">
                            {{
                              isFiltered()
                                ? 'Try adjusting your filters or search keywords.'
                                : 'Add your first environment secret or configuration key.'
                            }}
                          </p>
                          @if (!isFiltered()) {
                            <button ui-button size="sm" class="mt-2 text-xs" (click)="isAddOpen.set(true)">
                              <lucide-icon [img]="Plus" class="mr-1.5 size-3.5" />
                              Add Variable
                            </button>
                          }
                        </div>
                      </td>
                    </tr>
                  }
                </tbody>
              </table>
            </div>
          </div>
    
          <!-- Edit Variable Dialog -->
          <ui-dialog [open]="!!editingVariable()" (openChange)="onEditOpenChange($event)">
            @if (editingVariable(); as ev) {
              <ui-dialog-content class="sm:max-w-md">
                <div ui-dialog-header>
                  <h2 ui-dialog-title>Edit Environment Variable</h2>
                  <p ui-dialog-description>Update variable value and configured environment scopes.</p>
                </div>
                <div class="space-y-4 py-2">
                  <div class="space-y-1.5">
                    <label class="text-xs font-medium">Variable Key</label>
                    <ui-input
                      [value]="ev.key"
                      (valueChange)="patchEditing({ key: $event })"
                      class="font-mono text-xs uppercase"
                    />
                  </div>
    
                  <div class="space-y-1.5">
                    <label class="text-xs font-medium">Variable Value</label>
                    <ui-input
                      [value]="ev.value"
                      (valueChange)="patchEditing({ value: $event })"
                      [type]="ev.isSecret ? 'password' : 'text'"
                      class="font-mono text-xs"
                    />
                  </div>
    
                  <div class="space-y-2">
                    <span class="text-xs font-medium">Environments</span>
                    <div class="flex flex-wrap items-center gap-2">
                      @for (scope of scopeOptions; track scope.value) {
                        <label [class]="scopeLabelClass(ev.environments, scope, 'gap-1.5')">
                          <ui-checkbox
                            [checked]="ev.environments.includes(scope.value)"
                            (checkedChange)="toggleEditEnv(scope.value)"
                          />
                          <span [class]="scope.text + ' font-medium'">{{ scope.label }}</span>
                        </label>
                      }
                    </div>
                  </div>
    
                  <div class="flex items-center gap-2 pt-1">
                    <ui-checkbox
                      id="angular-edit-is-secret"
                      [checked]="ev.isSecret"
                      (checkedChange)="patchEditing({ isSecret: !!$event })"
                    />
                    <label for="angular-edit-is-secret" class="cursor-pointer text-xs font-medium select-none">
                      Encrypt as Secret
                    </label>
                  </div>
                </div>
                <div ui-dialog-footer>
                  <button ui-button variant="ghost" size="sm" (click)="editingVariable.set(null)">Cancel</button>
                  <button ui-button size="sm" (click)="saveEdit()">Save Changes</button>
                </div>
              </ui-dialog-content>
            }
          </ui-dialog>
    
          <!-- Import .env Dialog -->
          <ui-dialog [open]="isImportOpen()" (openChange)="isImportOpen.set($event)">
            <ui-dialog-content class="sm:max-w-lg">
              <div ui-dialog-header>
                <div class="flex items-center gap-2">
                  <div class="bg-primary/10 text-primary grid size-7 place-items-center rounded-md border">
                    <lucide-icon [img]="Upload" class="size-3.5" />
                  </div>
                  <h2 ui-dialog-title>Import .env File</h2>
                </div>
                <p ui-dialog-description>
                  Paste your raw .env, .env.local, or .env.production file contents to bulk add variables.
                </p>
              </div>
    
              <div class="space-y-4 py-2">
                <div class="space-y-1.5">
                  <label class="text-xs font-medium">.env File Content</label>
                  <ui-textarea
                    [value]="importText()"
                    (valueChange)="importText.set($event)"
                    [rows]="6"
                    placeholder="DATABASE_URL=postgresql://user:pass@host:5432/db&#10;STRIPE_SECRET_KEY=mock_key_...&#10;NEXT_PUBLIC_APP_URL=https://app.example.com"
                    class="font-mono text-xs"
                  />
                </div>
    
                <div class="space-y-2">
                  <span class="text-xs font-medium">Assign to Environments</span>
                  <div class="flex flex-wrap items-center gap-2">
                    @for (scope of scopeOptions; track scope.value) {
                      <label [class]="scopeLabelClass(importEnvs(), scope, 'gap-1.5')">
                        <ui-checkbox
                          [checked]="importEnvs().includes(scope.value)"
                          (checkedChange)="toggleImportEnv(scope.value)"
                        />
                        <span [class]="scope.text + ' font-medium'">{{ scope.label }}</span>
                      </label>
                    }
                  </div>
                </div>
    
                <div class="flex items-center gap-2 pt-1">
                  <ui-checkbox
                    id="angular-import-encrypt-all"
                    [checked]="importEncryptAll()"
                    (checkedChange)="importEncryptAll.set(!!$event)"
                  />
                  <label for="angular-import-encrypt-all" class="cursor-pointer text-xs font-medium select-none">
                    Encrypt all variables by default
                  </label>
                </div>
    
                @if (parsedImportItems().length > 0) {
                  <div class="bg-muted/60 flex items-center gap-2 rounded-md border px-3 py-2 text-xs">
                    <lucide-icon [img]="ShieldCheck" class="text-success size-4 shrink-0" />
                    <span>
                      Ready to import <strong>{{ parsedImportItems().length }}</strong> variable(s).
                    </span>
                  </div>
                }
              </div>
    
              <div ui-dialog-footer>
                <button ui-button variant="ghost" size="sm" (click)="isImportOpen.set(false)">Cancel</button>
                <button ui-button size="sm" [disabled]="parsedImportItems().length === 0" (click)="handleImport()">
                  Import {{ parsedImportItems().length > 0 ? '(' + parsedImportItems().length + ')' : '' }}
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiEnvironmentVariablesManagerComponent {
      protected readonly AlertCircle = AlertCircle
      protected readonly Check = Check
      protected readonly Copy = Copy
      protected readonly Download = Download
      protected readonly Eye = Eye
      protected readonly EyeOff = EyeOff
      protected readonly FileCode = FileCode
      protected readonly KeyRound = KeyRound
      protected readonly Lock = Lock
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly Pencil = Pencil
      protected readonly Plus = Plus
      protected readonly Search = Search
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Trash2 = Trash2
      protected readonly Upload = Upload
      protected readonly X = X
    
      protected readonly scopeOptions = scopeOptions
    
      @Input() set initialVariables(v: EnvironmentVariable[] | undefined) {
        this.variables.set((v ?? defaultVariables).map((item) => ({ ...item, environments: [...item.environments] })))
      }
      @Input() set initialAddOpen(v: boolean) {
        this.isAddOpen.set(!!v)
      }
      @Input() set initialImportOpen(v: boolean) {
        this.isImportOpen.set(!!v)
      }
      @Input() set defaultEnvironment(v: 'all' | EnvironmentScope) {
        this.activeTab.set(v ?? 'all')
      }
      @Input('class') className?: string
    
      readonly variables = signal<EnvironmentVariable[]>(
        defaultVariables.map((v) => ({ ...v, environments: [...v.environments] })),
      )
    
      readonly activeTab = signal<'all' | EnvironmentScope>('all')
      readonly searchQuery = signal('')
      readonly typeFilter = signal<EnvVarTypeFilter>('all')
      readonly revealAll = signal(false)
      readonly revealedMap = signal<Record<string, boolean>>({})
    
      readonly isAddOpen = signal(false)
      readonly isImportOpen = signal(false)
      readonly editingVariable = signal<EnvironmentVariable | null>(null)
    
      // Add variable state
      readonly newKey = signal('')
      readonly newValue = signal('')
      readonly newIsSecret = signal(true)
      readonly newEnvs = signal<EnvironmentScope[]>([...ALL_ENVS])
    
      // Import state
      readonly importText = signal('')
      readonly importEnvs = signal<EnvironmentScope[]>([...ALL_ENVS])
      readonly importEncryptAll = signal(true)
    
      // Copy state
      readonly copiedId = signal<string | null>(null)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
      // Monotonic suffix so ids stay unique even when two actions land in the same millisecond.
      private uid = 0
    
      private nextId(prefix: string): string {
        return `${prefix}-${Date.now()}-${this.uid++}`
      }
    
      constructor() {
        inject(DestroyRef).onDestroy(() => clearTimeout(this.copyTimer))
      }
    
      readonly filteredVariables = computed(() => {
        const tab = this.activeTab()
        const type = this.typeFilter()
        const q = this.searchQuery().toLowerCase().trim()
        return this.variables().filter((item) => {
          if (tab !== 'all' && !item.environments.includes(tab)) return false
          if (type === 'secret' && !item.isSecret) return false
          if (type === 'plain' && item.isSecret) return false
          if (q) {
            const keyMatch = item.key.toLowerCase().includes(q)
            const authorMatch = item.updatedBy.toLowerCase().includes(q)
            if (!keyMatch && !authorMatch) return false
          }
          return true
        })
      })
    
      readonly counts = computed(() => {
        const vars = this.variables()
        return {
          all: vars.length,
          production: vars.filter((v) => v.environments.includes('production')).length,
          preview: vars.filter((v) => v.environments.includes('preview')).length,
          development: vars.filter((v) => v.environments.includes('development')).length,
        }
      })
    
      readonly isFiltered = computed(
        () => !!this.searchQuery() || this.typeFilter() !== 'all' || this.activeTab() !== 'all',
      )
    
      readonly parsedImportItems = computed<ParsedImportItem[]>(() => {
        const text = this.importText()
        const encryptAll = this.importEncryptAll()
        if (!text.trim()) return []
        const results: ParsedImportItem[] = []
        for (const rawLine of text.split('\n')) {
          const line = rawLine.trim()
          if (!line || line.startsWith('#')) continue
          const eqIdx = line.indexOf('=')
          if (eqIdx > 0) {
            const k = line.slice(0, eqIdx).trim().toUpperCase()
            let v = line.slice(eqIdx + 1).trim()
            if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
              v = v.slice(1, -1)
            }
            if (k) {
              const isSecret =
                encryptAll ||
                k.includes('SECRET') ||
                k.includes('KEY') ||
                k.includes('PASSWORD') ||
                k.includes('TOKEN') ||
                k.includes('PRIVATE') ||
                k.includes('AUTH')
              results.push({ key: k, value: v, isSecret })
            }
          }
        }
        return results
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      get revealAllClass(): string {
        return cn('h-8.5 gap-1.5 text-xs shadow-xs', this.revealAll() && 'border-primary text-primary')
      }
    
      scopeLabelClass(selected: EnvironmentScope[], scope: ScopeOption, extra: string): string {
        return cn(
          'hover:bg-accent/50 flex cursor-pointer items-center rounded-md border px-2.5 py-1.5 text-xs select-none',
          extra,
          selected.includes(scope.value) && scope.active,
        )
      }
    
      mask(value: string): string {
        return maskString(value)
      }
    
      setActiveTab(value: string): void {
        this.activeTab.set(value as 'all' | EnvironmentScope)
      }
    
      setTypeFilter(value: string): void {
        this.typeFilter.set(value as EnvVarTypeFilter)
      }
    
      isRevealed(id: string): boolean {
        if (this.revealAll()) return true
        return Boolean(this.revealedMap()[id])
      }
    
      toggleReveal(id: string): void {
        this.revealedMap.update((prev) => ({ ...prev, [id]: !prev[id] }))
      }
    
      toggleRevealAll(): void {
        const next = !this.revealAll()
        this.revealAll.set(next)
        if (!next) this.revealedMap.set({})
      }
    
      async copyValue(text: string, id: string): Promise<void> {
        try {
          await navigator.clipboard.writeText(text)
          this.copiedId.set(id)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => this.copiedId.set(null), 1600)
        } catch {
          // Clipboard fallback
        }
      }
    
      private static toggleScope(list: EnvironmentScope[], env: EnvironmentScope): EnvironmentScope[] {
        if (list.includes(env)) return list.length > 1 ? list.filter((e) => e !== env) : list
        return [...list, env]
      }
    
      toggleNewEnv(env: EnvironmentScope): void {
        this.newEnvs.update((prev) => UiEnvironmentVariablesManagerComponent.toggleScope(prev, env))
      }
    
      toggleImportEnv(env: EnvironmentScope): void {
        this.importEnvs.update((prev) => UiEnvironmentVariablesManagerComponent.toggleScope(prev, env))
      }
    
      toggleEditEnv(env: EnvironmentScope): void {
        const ev = this.editingVariable()
        if (!ev) return
        this.editingVariable.set({
          ...ev,
          environments: UiEnvironmentVariablesManagerComponent.toggleScope(ev.environments, env),
        })
      }
    
      resetAddForm(): void {
        this.newKey.set('')
        this.newValue.set('')
        this.newIsSecret.set(true)
        this.newEnvs.set([...ALL_ENVS])
        this.isAddOpen.set(false)
      }
    
      handleAddVariable(): void {
        const key = this.newKey().trim().toUpperCase()
        const val = this.newValue().trim()
        if (!key || !val) return
        const newVar: EnvironmentVariable = {
          id: this.nextId('env'),
          key,
          value: val,
          environments: [...this.newEnvs()],
          isSecret: this.newIsSecret(),
          updatedAt: 'Just now',
          updatedBy: 'you',
        }
        this.variables.update((prev) => [newVar, ...prev])
        this.resetAddForm()
      }
    
      handleDuplicate(item: EnvironmentVariable): void {
        const duplicateVar: EnvironmentVariable = {
          ...item,
          environments: [...item.environments],
          id: this.nextId('env'),
          key: `${item.key}_COPY`,
          updatedAt: 'Just now',
          updatedBy: 'you',
        }
        this.variables.update((prev) => {
          const index = prev.findIndex((v) => v.id === item.id)
          if (index >= 0) {
            const next = [...prev]
            next.splice(index + 1, 0, duplicateVar)
            return next
          }
          return [duplicateVar, ...prev]
        })
      }
    
      handleDelete(id: string): void {
        this.variables.update((prev) => prev.filter((v) => v.id !== id))
      }
    
      openEdit(item: EnvironmentVariable): void {
        this.editingVariable.set({ ...item, environments: [...item.environments] })
      }
    
      onEditOpenChange(open: boolean): void {
        if (!open) this.editingVariable.set(null)
      }
    
      patchEditing(patch: Partial<EnvironmentVariable>): void {
        const ev = this.editingVariable()
        if (ev) this.editingVariable.set({ ...ev, ...patch })
      }
    
      saveEdit(): void {
        const ev = this.editingVariable()
        if (!ev) return
        const key = ev.key.trim().toUpperCase()
        const val = ev.value.trim()
        if (!key || !val) return
        this.variables.update((prev) =>
          prev.map((v) => (v.id === ev.id ? { ...ev, key, value: val, updatedAt: 'Just now', updatedBy: 'you' } : v)),
        )
        this.editingVariable.set(null)
      }
    
      handleImport(): void {
        const items = this.parsedImportItems()
        if (items.length === 0) return
        const envs = this.importEnvs()
        const stamp = Date.now()
        const newVars: EnvironmentVariable[] = items.map((item, index) => ({
          id: `env-imported-${stamp}-${index}`,
          key: item.key,
          value: item.value,
          environments: [...envs],
          isSecret: item.isSecret,
          updatedAt: 'Imported just now',
          updatedBy: 'you',
        }))
        this.variables.update((prev) => [...newVars, ...prev])
        this.importText.set('')
        this.isImportOpen.set(false)
      }
    
      exportEnvFile(): void {
        const lines = this.variables().map((v) => `# ${v.environments.join(', ')}\n${v.key}=${v.value}`)
        const blob = new Blob([lines.join('\n\n')], { type: 'text/plain;charset=utf-8' })
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = '.env.production'
        a.click()
        URL.revokeObjectURL(url)
      }
    }
    
  • src/app/components/blocks/environment-variables-manager/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/environment-variables-manager.json