Environment Variables Manager
blockdevopsVercel and Supabase style environment variables and secrets manager with environment scoping, mask/reveal toggles, bulk import, and inline editing.
Installation
$pnpm dlx uipkge-ng@latest add environment-variables-manager$npx uipkge-ng@latest add environment-variables-manager$yarn dlx uipkge-ng@latest add environment-variables-manager$bunx uipkge-ng@latest add environment-variables-managerManifest:
https://uipkge.dev/r/angular/environment-variables-manager.jsonInstalls to:src/app/components/blocks/environment-variables-manager/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
EnvironmentVariableinterface EnvironmentVariable {
id: string
key: string
value: string
environments: EnvironmentScope[]
isSecret: boolean
updatedAt: string
updatedBy: string
}ScopeOptioninterface ScopeOption {
value: EnvironmentScope
label: string
/** Label border/background when the scope is checked. */
active: string
/** Label text colour. */
text: string
}ParsedImportIteminterface 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 STRIPE_SECRET_KEY=mock_key_... 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
export { UiEnvironmentVariablesManagerComponent, type EnvironmentScope, type EnvVarTypeFilter, type EnvironmentVariable, } from './environment-variables-manager.component'
Raw manifest:https://uipkge.dev/r/angular/environment-variables-manager.json