Menu

Change language

GitHub repo

Api Keys

blocksecurity

Developer API-key manager in a SectionCard. Self-contained stateful block with stub keys, reveal/copy/revoke row actions, an empty state, and a create-key dialog.

Installation

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

Variants

Loading interactive previews…

Schema

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

ApiKeyItem
interface ApiKeyItem {
  id: string
  name: string
  environment: ApiKeyEnvironment
  value: string
  createdAt: Date
  lastUsedAt: Date | null
  status: ApiKeyStatus
}

Files installed (2)

  • src/app/components/blocks/api-keys/api-keys.component.ts14.1 kB
    // Developer API-key manager in a SectionCard: self-contained stateful block with stub keys,
    // reveal/copy/revoke row actions, an empty state, and a create-key dialog.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, inject, signal } from '@angular/core'
    import { Check, Copy, Eye, EyeOff, KeyRound, LucideAngularModule, Plus } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSectionCardComponent } from '@/ui/section-card/section-card.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    
    export type ApiKeyEnvironment = 'production' | 'staging' | 'development'
    export type ApiKeyStatus = 'active' | 'revoked' | 'expiring'
    export type ApiKeysDensity = 'default' | 'compact'
    
    export interface ApiKeyItem {
      id: string
      name: string
      environment: ApiKeyEnvironment
      value: string
      createdAt: Date
      lastUsedAt: Date | null
      status: ApiKeyStatus
    }
    
    const statusMeta: Record<ApiKeyStatus, { label: string; variant: 'success' | 'warning' | 'destructive' }> = {
      active: { label: 'Active', variant: 'success' },
      expiring: { label: 'Expires soon', variant: 'warning' },
      revoked: { label: 'Revoked', variant: 'destructive' },
    }
    
    const environmentPrefix: Record<ApiKeyEnvironment, string> = {
      production: 'uipkge_live_',
      staging: 'sk_stg_',
      development: 'sk_dev_',
    }
    
    function maskKeyValue(value: string): string {
      const parts = value.split('_')
      const prefix = parts.length > 2 ? `${parts[0]}_${parts[1]}_` : ''
      return `${prefix}••••••••${value.slice(-4)}`
    }
    
    function formatDate(d: Date): string {
      return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
    }
    
    function createStubKeys(now: Date): ApiKeyItem[] {
      const daysAgo = (days: number) => new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
      const hoursAgo = (hours: number) => new Date(now.getTime() - hours * 60 * 60 * 1000)
      return [
        {
          id: 'key-prod-server',
          name: 'Production server',
          environment: 'production',
          value: 'uipkge_live_51HxQp9mZvKYlo2C4f2a',
          createdAt: daysAgo(94),
          lastUsedAt: hoursAgo(2),
          status: 'active',
        },
        {
          id: 'key-ci-pipeline',
          name: 'CI pipeline (GitHub Actions)',
          environment: 'staging',
          value: 'sk_stg_6JdRw3nAwLZmp7xQb91e',
          createdAt: daysAgo(41),
          lastUsedAt: hoursAgo(26),
          status: 'active',
        },
        {
          id: 'key-mobile-backend',
          name: 'Mobile app backend',
          environment: 'production',
          value: 'uipkge_live_8KfSx1oCyUZiqw9Ma2b7',
          createdAt: daysAgo(27),
          lastUsedAt: hoursAgo(5),
          status: 'expiring',
        },
        {
          id: 'key-local-dev',
          name: 'Local development',
          environment: 'development',
          value: 'sk_dev_k2MnQ8tReFvBgy5Xc40d',
          createdAt: daysAgo(12),
          lastUsedAt: daysAgo(3),
          status: 'active',
        },
        {
          id: 'key-legacy-webhooks',
          name: 'Legacy webhook integration',
          environment: 'production',
          value: 'uipkge_live_2PvBgR6nTxAokdJe5f8c',
          createdAt: daysAgo(210),
          lastUsedAt: daysAgo(64),
          status: 'revoked',
        },
      ]
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-api-keys, [ui-api-keys]',
      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,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiInputComponent,
        UiSectionCardComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
      ],
      template: `
        <div data-slot="api-keys" [class]="rootClass">
          <div ui-section-card title="API Keys" description="Secret keys used to authenticate requests to your API.">
            <button ui-button size="sm" slot="header-action" (click)="openCreate()">
              <lucide-icon [img]="Plus" class="size-4" />
              Create key
            </button>
    
            @if (keys().length === 0) {
              <div class="flex flex-col items-center justify-center px-6 py-14 text-center">
                <div class="bg-muted text-muted-foreground mx-auto grid size-12 place-items-center rounded-full">
                  <lucide-icon [img]="KeyRound" class="size-5" />
                </div>
                <p class="mt-3 text-sm font-medium">No API keys yet</p>
                <p class="text-muted-foreground mt-0.5 max-w-xs text-xs">
                  Create a key to start authenticating requests against your API.
                </p>
                <button ui-button size="sm" class="mt-4" (click)="openCreate()">Create your first key</button>
              </div>
            } @else {
              <ul class="-my-4 divide-y">
                @for (key of keys(); track key.id) {
                  <li [class]="rowClass">
                    <div class="min-w-0 flex-1">
                      <div class="flex items-center gap-2">
                        <p class="truncate text-sm font-medium">{{ key.name }}</p>
                        <span ui-badge [variant]="statusVariant(key.status)">{{ statusLabel(key.status) }}</span>
                      </div>
                      <p class="text-muted-foreground mt-0.5 truncate text-xs">
                        Created {{ formatDate(key.createdAt) }} · Last used {{ lastUsedText(key.lastUsedAt) }}
                      </p>
                    </div>
    
                    <code class="bg-muted hidden shrink-0 rounded px-2 py-1 font-mono text-xs md:block">
                      {{ isRevealed(key.id) ? key.value : mask(key.value) }}
                    </code>
    
                    <div class="flex shrink-0 items-center gap-1">
                      <button
                        ui-button
                        variant="ghost"
                        size="icon-sm"
                        [attr.aria-label]="isRevealed(key.id) ? 'Hide key value' : 'Reveal key value'"
                        (click)="toggleRevealed(key.id)"
                      >
                        @if (isRevealed(key.id)) {
                          <lucide-icon [img]="EyeOff" class="size-4" />
                        } @else {
                          <lucide-icon [img]="Eye" class="size-4" />
                        }
                      </button>
                      <button
                        ui-button
                        variant="ghost"
                        size="icon-sm"
                        [attr.aria-label]="copiedId() === key.id ? 'Copied' : 'Copy key value'"
                        (click)="copyKey(key)"
                      >
                        @if (copiedId() === key.id) {
                          <lucide-icon [img]="Check" class="text-success size-4" />
                        } @else {
                          <lucide-icon [img]="Copy" class="text-muted-foreground size-4" />
                        }
                      </button>
                      @if (key.status !== 'revoked') {
                        <button
                          ui-button
                          size="sm"
                          [variant]="confirmingId() === key.id ? 'destructive' : 'ghost'"
                          [class]="confirmingId() === key.id ? undefined : 'text-muted-foreground hover:text-destructive'"
                          (click)="requestRevoke(key.id)"
                          (blur)="cancelRevoke(key.id)"
                        >
                          {{ confirmingId() === key.id ? 'Confirm?' : 'Revoke' }}
                        </button>
                      }
                    </div>
                  </li>
                }
              </ul>
            }
          </div>
    
          <ui-dialog [open]="createOpen()" (openChange)="handleCreateOpenChange($event)">
            <ui-dialog-content class="sm:max-w-md">
              <div ui-dialog-header>
                <h2 ui-dialog-title>Create API key</h2>
                <p ui-dialog-description>
                  Generate a new secret key. The full value is only shown once — store it somewhere safe.
                </p>
              </div>
              <div class="grid gap-4 py-2">
                <div class="grid gap-1.5">
                  <label for="angular-api-key-name" class="text-sm leading-none font-medium">Name</label>
                  <ui-input
                    id="angular-api-key-name"
                    [value]="newName()"
                    (valueChange)="newName.set($event)"
                    placeholder="e.g. Production server"
                    (keydown.enter)="createKey()"
                  />
                </div>
                <div class="grid gap-1.5">
                  <label for="angular-api-key-environment" class="text-sm leading-none font-medium">Environment</label>
                  <ui-select [value]="newEnv()" (valueChange)="setNewEnv($event)">
                    <button ui-select-trigger id="angular-api-key-environment" class="w-full">
                      <ui-select-value placeholder="Choose an environment" />
                    </button>
                    <ui-select-content>
                      <ui-select-item value="production">Production</ui-select-item>
                      <ui-select-item value="staging">Staging</ui-select-item>
                      <ui-select-item value="development">Development</ui-select-item>
                    </ui-select-content>
                  </ui-select>
                </div>
              </div>
              <div ui-dialog-footer>
                <button ui-button variant="outline" (click)="createOpen.set(false)">Cancel</button>
                <button ui-button [disabled]="!newName().trim()" (click)="createKey()">Create key</button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiApiKeysComponent {
      protected readonly Check = Check
      protected readonly Copy = Copy
      protected readonly Eye = Eye
      protected readonly EyeOff = EyeOff
      protected readonly KeyRound = KeyRound
      protected readonly Plus = Plus
    
      private readonly now = new Date()
    
      @Input() set initialKeys(v: ApiKeyItem[] | undefined) {
        this.keys.set(v ? v.map((k) => ({ ...k })) : createStubKeys(this.now))
      }
      @Input() set initialCreateOpen(v: boolean) {
        this.createOpen.set(!!v)
      }
      @Input() set density(v: ApiKeysDensity) {
        this.densityValue.set(v ?? 'default')
      }
      @Input('class') className?: string
    
      readonly keys = signal<ApiKeyItem[]>(createStubKeys(this.now))
      readonly revealed = signal<Record<string, boolean>>({})
      readonly copiedId = signal<string | null>(null)
      readonly confirmingId = signal<string | null>(null)
      readonly createOpen = signal(false)
      readonly newName = signal('')
      readonly newEnv = signal<ApiKeyEnvironment>('production')
      readonly densityValue = signal<ApiKeysDensity>('default')
    
      private copyTimer: ReturnType<typeof setTimeout> | undefined
      private confirmTimer: ReturnType<typeof setTimeout> | undefined
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          clearTimeout(this.copyTimer)
          clearTimeout(this.confirmTimer)
        })
      }
    
      get rootClass(): string {
        return cn('w-full', this.className)
      }
    
      get rowClass(): string {
        return cn('flex items-center gap-3 sm:gap-4', this.densityValue() === 'compact' ? 'py-2.5' : 'py-4')
      }
    
      statusLabel(status: ApiKeyStatus): string {
        return statusMeta[status].label
      }
    
      statusVariant(status: ApiKeyStatus): 'success' | 'warning' | 'destructive' {
        return statusMeta[status].variant
      }
    
      mask(value: string): string {
        return maskKeyValue(value)
      }
    
      formatDate(d: Date): string {
        return formatDate(d)
      }
    
      isRevealed(id: string): boolean {
        return Boolean(this.revealed()[id])
      }
    
      lastUsedText(d: Date | null): string {
        if (!d) return 'Never'
        const minutes = Math.max(1, Math.round((this.now.getTime() - d.getTime()) / 60000))
        if (minutes < 60) return `${minutes}m ago`
        const hours = Math.round(minutes / 60)
        if (hours < 24) return `${hours}h ago`
        return `${Math.round(hours / 24)}d ago`
      }
    
      setNewEnv(v: string): void {
        this.newEnv.set(v as ApiKeyEnvironment)
      }
    
      toggleRevealed(id: string): void {
        this.revealed.update((r) => ({ ...r, [id]: !r[id] }))
      }
    
      async copyKey(key: ApiKeyItem): Promise<void> {
        try {
          await navigator.clipboard.writeText(key.value)
          this.copiedId.set(key.id)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => this.copiedId.set(null), 1600)
        } catch {
          // Clipboard unavailable (e.g. insecure context) — skip the feedback swap.
        }
      }
    
      requestRevoke(id: string): void {
        if (this.confirmingId() === id) {
          this.revokeKey(id)
          return
        }
        this.confirmingId.set(id)
        clearTimeout(this.confirmTimer)
        this.confirmTimer = setTimeout(() => this.confirmingId.set(null), 3000)
      }
    
      cancelRevoke(id: string): void {
        if (this.confirmingId() === id) {
          clearTimeout(this.confirmTimer)
          this.confirmingId.set(null)
        }
      }
    
      revokeKey(id: string): void {
        clearTimeout(this.confirmTimer)
        this.confirmingId.set(null)
        this.keys.update((ks) => ks.map((k) => (k.id === id ? { ...k, status: 'revoked' as const } : k)))
      }
    
      openCreate(): void {
        this.newName.set('')
        this.newEnv.set('production')
        this.createOpen.set(true)
      }
    
      handleCreateOpenChange(open: boolean): void {
        if (open) this.openCreate()
        else this.createOpen.set(false)
      }
    
      createKey(): void {
        const name = this.newName().trim()
        if (!name) return
        const random = Array.from({ length: 2 }, () => Math.random().toString(36).slice(2, 14)).join('')
        const env = this.newEnv()
        this.keys.update((ks) => [
          {
            id: `key-${Date.now()}`,
            name,
            environment: env,
            value: `${environmentPrefix[env]}${random}`,
            createdAt: new Date(),
            lastUsedAt: null,
            status: 'active',
          },
          ...ks,
        ])
        this.createOpen.set(false)
      }
    }
    
  • src/app/components/blocks/api-keys/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/api-keys.json