Menu

Change language

GitHub repo

Secrets Rotation Scheduler

blocksecurity

AWS Secrets Manager style automated credential rotation scheduler and security key audit with lifecycle telemetry cards, zero-downtime dual-version rotation workflow, rotation lambda log inspection, and schedule configuration dialogs.

Installation

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

Variants

Loading interactive previews…

Schema

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

RotationSchedule
interface RotationSchedule {
  interval: string
  daysInterval: number
  lambdaArn?: string
  zeroDowntimeVerified: boolean
}
ManagedSecret
interface ManagedSecret {
  id: string
  name: string
  arn: string
  type: SecretType
  schedule: RotationSchedule
  lastRotated: string
  nextRotationDue: string
  dueRelative: string
  status: RotationStatus
  currentVersion: string
  stagingVersion?: string
  lastLogSnippet?: string
}
SecretsRotationStats
interface SecretsRotationStats {
  totalSecrets: number
  autoRotationActive: number
  autoRotationPercentage: number
  dueSoonCount: number
  overdueCount: number
}

Files installed (2)

  • src/app/components/blocks/secrets-rotation-scheduler/secrets-rotation-scheduler.component.ts46.5 kB
    // AWS Secrets Manager style automated credential rotation scheduler: lifecycle
    // telemetry cards, searchable/filterable secrets table, zero-downtime instant-rotate
    // dialog, schedule create/edit dialog, and rotation lambda log viewer. Port of the
    // React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      AlertTriangle,
      Check,
      CheckCircle2,
      Clock,
      Copy,
      Database,
      Globe,
      Info,
      KeyRound,
      Lock,
      LucideAngularModule,
      MoreHorizontal,
      Pause,
      Pencil,
      Play,
      Plus,
      RefreshCw,
      Search,
      Server,
      ShieldCheck,
      Terminal,
      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 {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuLabelComponent,
      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'
    
    export type SecretType = 'Postgres DB' | 'API Key' | 'RSA Keypair' | 'KMS Key' | 'Redis Auth' | 'MySQL DB'
    export type RotationStatus = 'healthy' | 'due-soon' | 'manual-needed' | 'paused' | 'rotating'
    
    export interface RotationSchedule {
      interval: string
      daysInterval: number
      lambdaArn?: string
      zeroDowntimeVerified: boolean
    }
    
    export interface ManagedSecret {
      id: string
      name: string
      arn: string
      type: SecretType
      schedule: RotationSchedule
      lastRotated: string
      nextRotationDue: string
      dueRelative: string
      status: RotationStatus
      currentVersion: string
      stagingVersion?: string
      lastLogSnippet?: string
    }
    
    export interface SecretsRotationStats {
      totalSecrets: number
      autoRotationActive: number
      autoRotationPercentage: number
      dueSoonCount: number
      overdueCount: number
    }
    
    const defaultSecrets: ManagedSecret[] = [
      {
        id: 'sec-1',
        name: 'prod/postgres/master-credentials',
        arn: 'arn:aws:secretsmanager:us-east-1:182938491029:secret:prod/postgres/master-cred-9aF1x',
        type: 'Postgres DB',
        schedule: {
          interval: 'Every 30 Days',
          daysInterval: 30,
          lambdaArn: 'arn:aws:lambda:us-east-1:182938491029:function:RotatePostgresMaster',
          zeroDowntimeVerified: true,
        },
        lastRotated: 'Aug 10, 2026',
        nextRotationDue: 'Sep 09, 2026',
        dueRelative: 'in 19 days',
        status: 'healthy',
        currentVersion: 'v2.4 (AWSCURRENT)',
        stagingVersion: 'v2.5 (AWSPENDING)',
        lastLogSnippet:
          'Postgres dual-user handshake verified. Master role privileges synchronized with 0 connection terminations.',
      },
      {
        id: 'sec-2',
        name: 'prod/stripe/webhook-secret',
        arn: 'arn:aws:secretsmanager:us-east-1:182938491029:secret:prod/stripe/wh-sec-48k2p',
        type: 'API Key',
        schedule: {
          interval: 'Every 90 Days',
          daysInterval: 90,
          lambdaArn: 'arn:aws:lambda:us-east-1:182938491029:function:RotateStripeWebhook',
          zeroDowntimeVerified: true,
        },
        lastRotated: 'Jul 14, 2026',
        nextRotationDue: 'Aug 22, 2026',
        dueRelative: 'in 24 hours',
        status: 'due-soon',
        currentVersion: 'v1.8 (AWSCURRENT)',
        stagingVersion: 'v1.9 (AWSPENDING)',
        lastLogSnippet: 'Dual webhook signature validation window active. Webhook endpoint test passed with 200 OK.',
      },
      {
        id: 'sec-3',
        name: 'prod/jwt/signing-key-rsa',
        arn: 'arn:aws:secretsmanager:us-east-1:182938491029:secret:prod/jwt/rsa-pair-71b3e',
        type: 'RSA Keypair',
        schedule: {
          interval: 'Every 7 Days',
          daysInterval: 7,
          lambdaArn: 'arn:aws:lambda:us-east-1:182938491029:function:RotateJWTRSAKeys',
          zeroDowntimeVerified: true,
        },
        lastRotated: 'Aug 18, 2026',
        nextRotationDue: 'Aug 25, 2026',
        dueRelative: 'in 4 days',
        status: 'healthy',
        currentVersion: 'v9.1 (AWSCURRENT)',
        stagingVersion: 'v9.2 (AWSPENDING)',
        lastLogSnippet:
          'JWKS endpoint updated with secondary public key kid:rsa-2026-w34. Token signature verification succeeded.',
      },
      {
        id: 'sec-4',
        name: 'prod/aws/kms-envelope-key',
        arn: 'arn:aws:kms:us-east-1:182938491029:key/mrk-8492019a-9e1b-4f21-99ad',
        type: 'KMS Key',
        schedule: {
          interval: 'Every 365 Days',
          daysInterval: 365,
          lambdaArn: 'arn:aws:lambda:us-east-1:182938491029:function:KmsEnvelopeRotationHandler',
          zeroDowntimeVerified: true,
        },
        lastRotated: 'Oct 01, 2025',
        nextRotationDue: 'Oct 01, 2026',
        dueRelative: 'in 41 days',
        status: 'healthy',
        currentVersion: 'v3.0 (AWSCURRENT)',
        stagingVersion: 'v3.1 (AWSPENDING)',
        lastLogSnippet:
          'Hardware Security Module (HSM) backing key refreshed. Transparent decryption verified for existing ciphertexts.',
      },
      {
        id: 'sec-5',
        name: 'prod/redis/cache-auth',
        arn: 'arn:aws:secretsmanager:us-east-1:182938491029:secret:prod/redis/auth-token-6c90d',
        type: 'Redis Auth',
        schedule: {
          interval: 'Manual Only',
          daysInterval: 0,
          zeroDowntimeVerified: false,
        },
        lastRotated: 'May 12, 2026',
        nextRotationDue: 'Aug 20, 2026',
        dueRelative: 'Overdue (1 day)',
        status: 'manual-needed',
        currentVersion: 'v1.0 (AWSCURRENT)',
        stagingVersion: undefined,
        lastLogSnippet:
          'Automated lambda execution unconfigured. Dual-AUTH token rotation requires manual trigger or handler attachment.',
      },
    ]
    
    const defaultStats: SecretsRotationStats = {
      totalSecrets: 18,
      autoRotationActive: 15,
      autoRotationPercentage: 83.3,
      dueSoonCount: 2,
      overdueCount: 0,
    }
    
    const SECRET_TYPES: SecretType[] = ['Postgres DB', 'MySQL DB', 'API Key', 'RSA Keypair', 'KMS Key', 'Redis Auth']
    const INTERVALS = ['Every 7 Days', 'Every 30 Days', 'Every 60 Days', 'Every 90 Days', 'Every 365 Days', 'Manual Only']
    
    function typeBadgeVariant(type: SecretType): 'default' | 'secondary' | 'outline' | 'info' | 'warning' {
      switch (type) {
        case 'Postgres DB':
        case 'MySQL DB':
          return 'info'
        case 'API Key':
          return 'secondary'
        case 'RSA Keypair':
          return 'outline'
        case 'KMS Key':
          return 'default'
        case 'Redis Auth':
          return 'warning'
        default:
          return 'outline'
      }
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-secrets-rotation-scheduler, [ui-secrets-rotation-scheduler]',
      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,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuLabelComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="secrets-rotation-scheduler" [class]="rootClass">
          <!-- Header -->
          <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">
                <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                  <lucide-icon [img]="Lock" class="size-4.5" />
                </div>
                <h1 class="text-foreground text-2xl font-bold tracking-tight">
                  Automated Secrets Rotation & Key Lifecycle
                </h1>
              </div>
              <p class="text-muted-foreground text-sm">
                Configure zero-downtime rotation schedules for database credentials, API keys, and certificates.
              </p>
            </div>
    
            <div class="flex items-center gap-2">
              <button ui-button (click)="openScheduleModal()">
                <lucide-icon [img]="Plus" class="mr-1.5 size-4" />
                <span>Schedule New Rotation</span>
              </button>
            </div>
          </div>
    
          <!-- Notification / Action banner -->
          @if (bannerMessage(); as banner) {
            <div class="border-border bg-card flex items-center justify-between gap-3 rounded-lg border p-3 shadow-xs">
              <div class="flex items-center gap-2.5 text-sm">
                @if (banner.type === 'success') {
                  <lucide-icon [img]="CheckCircle2" class="text-success size-4 shrink-0" />
                } @else {
                  <lucide-icon [img]="Info" class="text-primary size-4 shrink-0" />
                }
                <span class="text-foreground font-medium">{{ banner.text }}</span>
              </div>
              <button
                ui-button
                variant="ghost"
                size="icon"
                class="size-7"
                aria-label="Dismiss"
                (click)="bannerMessage.set(null)"
              >
                <lucide-icon [img]="X" class="size-3.5" />
                <span class="sr-only">Dismiss</span>
              </button>
            </div>
          }
    
          <!-- 4 Key Lifecycle Telemetry Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="shadow-xs">
              <div ui-card-header class="flex flex-row items-center justify-between pb-2">
                <h3 ui-card-title class="text-muted-foreground text-sm font-medium">Total Managed Secrets</h3>
                <div class="bg-primary/10 text-primary rounded-lg p-2">
                  <lucide-icon [img]="KeyRound" class="size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1">
                <div class="text-foreground text-2xl font-bold tracking-tight">{{ stats().totalSecrets }} credentials</div>
                <p class="text-muted-foreground text-xs">Across 4 cloud regions</p>
              </div>
            </div>
    
            <div ui-card class="shadow-xs">
              <div ui-card-header class="flex flex-row items-center justify-between pb-2">
                <h3 ui-card-title class="text-muted-foreground text-sm font-medium">Auto-Rotation Active</h3>
                <div class="bg-success/10 text-success rounded-lg p-2">
                  <lucide-icon [img]="RefreshCw" class="size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1">
                <div class="flex items-center gap-2">
                  <span class="text-foreground text-2xl font-bold tracking-tight">
                    {{ stats().autoRotationActive }} / {{ stats().totalSecrets }} secrets
                  </span>
                  <span ui-badge variant="success" class="text-xs">{{ stats().autoRotationPercentage }}%</span>
                </div>
                <p class="text-muted-foreground text-xs">Zero-downtime dual versioning enabled</p>
              </div>
            </div>
    
            <div ui-card class="border-warning/30 bg-warning/5 bg-warning/10 shadow-xs">
              <div ui-card-header class="flex flex-row items-center justify-between pb-2">
                <h3 ui-card-title class="text-warning text-sm font-medium">Rotation Due Soon</h3>
                <div class="bg-warning/20 text-warning rounded-lg p-2">
                  <lucide-icon [img]="Clock" class="size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1">
                <div class="flex items-center gap-2">
                  <span class="text-warning text-2xl font-bold tracking-tight">{{ stats().dueSoonCount }} secrets</span>
                  <span ui-badge variant="warning" class="text-xs">Next 48h</span>
                </div>
                <p class="text-warning/90 text-xs">Automated queue pre-scheduled</p>
              </div>
            </div>
    
            <div ui-card class="shadow-xs">
              <div ui-card-header class="flex flex-row items-center justify-between pb-2">
                <h3 ui-card-title class="text-muted-foreground text-sm font-medium">Expired / Overdue</h3>
                <div class="bg-success/10 text-success rounded-lg p-2">
                  <lucide-icon [img]="ShieldCheck" class="size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1">
                <div class="flex items-center gap-2">
                  <span class="text-foreground text-2xl font-bold tracking-tight">
                    {{ stats().overdueCount }} critical overdue
                  </span>
                  <span ui-badge variant="outline" class="text-xs">Compliant</span>
                </div>
                <p class="text-muted-foreground text-xs">Zero security compliance violations</p>
              </div>
            </div>
          </div>
    
          <!-- Secrets Rotation Table Card -->
          <div ui-card class="shadow-xs">
            <div ui-card-header class="pb-3">
              <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <h3 ui-card-title class="text-lg font-semibold">Managed Secrets & Automated Rotation</h3>
                  <p ui-card-description class="text-xs">
                    {{ filteredSecrets().length }} of {{ secrets().length }} cryptographic secrets configured for automated
                    lifecycle management.
                  </p>
                </div>
    
                <div class="flex flex-wrap items-center gap-2">
                  <div class="relative w-full min-w-0 sm:w-64">
                    <lucide-icon
                      [img]="Search"
                      class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
                    />
                    <ui-input
                      [value]="search()"
                      (valueChange)="search.set($event)"
                      placeholder="Search secrets or ARN"
                      class="h-8 pl-9 text-xs"
                    />
                  </div>
    
                  <ui-select [value]="statusFilter()" (valueChange)="statusFilter.set($event)">
                    <button ui-select-trigger class="h-8 w-36 text-xs">
                      <ui-select-value placeholder="All Statuses" />
                    </button>
                    <ui-select-content>
                      <ui-select-item value="all">All Statuses</ui-select-item>
                      <ui-select-item value="healthy">Automated · Healthy</ui-select-item>
                      <ui-select-item value="due-soon">Due Soon (&lt;48h)</ui-select-item>
                      <ui-select-item value="manual-needed">Manual Needed</ui-select-item>
                      <ui-select-item value="paused">Paused</ui-select-item>
                    </ui-select-content>
                  </ui-select>
    
                  <span ui-badge variant="outline" class="hidden h-8 items-center gap-1 font-mono text-xs md:inline-flex">
                    <lucide-icon [img]="Globe" class="size-3" />
                    <span>AWS us-east-1</span>
                  </span>
                </div>
              </div>
            </div>
    
            <div ui-card-content class="p-0">
              <div class="overflow-x-auto">
                <table ui-table>
                  <thead ui-table-header>
                    <tr ui-table-row class="hover:bg-transparent">
                      <th ui-table-head class="min-w-[260px]">Secret Name & ARN Identifier</th>
                      <th ui-table-head class="min-w-[130px]">Secret Type</th>
                      <th ui-table-head class="min-w-[170px]">Rotation Interval</th>
                      <th ui-table-head class="min-w-[200px]">Last Rotated & Next Due</th>
                      <th ui-table-head class="min-w-[170px]">Rotation Status</th>
                      <th ui-table-head class="w-16 text-right">Actions</th>
                    </tr>
                  </thead>
                  <tbody ui-table-body>
                    @for (secret of filteredSecrets(); track secret.id) {
                      <tr ui-table-row class="group transition-colors">
                        <td ui-table-cell class="py-3.5">
                          <div class="space-y-1">
                            <div class="flex items-center gap-1.5">
                              <span class="text-foreground text-sm font-medium">{{ secret.name }}</span>
                              @if (rotatingSecretId() === secret.id) {
                                <lucide-icon
                                  [img]="RefreshCw"
                                  class="text-primary size-3.5 animate-spin"
                                  aria-label="Rotating now"
                                />
                              }
                            </div>
                            <div class="flex items-center gap-1.5">
                              <code
                                class="text-muted-foreground max-w-[260px] truncate font-mono text-xs sm:max-w-[320px]"
                                [attr.title]="secret.arn"
                              >
                                {{ secret.arn }}
                              </code>
                              <button
                                type="button"
                                class="text-muted-foreground hover:text-foreground inline-flex size-4 shrink-0 items-center justify-center transition-colors"
                                [attr.aria-label]="'Copy ARN for ' + secret.name"
                                (click)="copyToClipboard(secret.arn, secret.id)"
                              >
                                @if (copiedArn() === secret.id) {
                                  <lucide-icon [img]="Check" class="text-success size-3" />
                                } @else {
                                  <lucide-icon [img]="Copy" class="size-3" />
                                }
                              </button>
                            </div>
                          </div>
                        </td>
                        <td ui-table-cell class="py-3.5">
                          <span ui-badge [variant]="typeVariant(secret.type)" class="gap-1 text-xs">
                            @if (secret.type === 'Postgres DB' || secret.type === 'MySQL DB') {
                              <lucide-icon [img]="Database" class="size-3" />
                            } @else if (secret.type === 'API Key') {
                              <lucide-icon [img]="KeyRound" class="size-3" />
                            } @else if (secret.type === 'RSA Keypair') {
                              <lucide-icon [img]="ShieldCheck" class="size-3" />
                            } @else if (secret.type === 'KMS Key') {
                              <lucide-icon [img]="Lock" class="size-3" />
                            } @else if (secret.type === 'Redis Auth') {
                              <lucide-icon [img]="Server" class="size-3" />
                            }
                            <span>{{ secret.type }}</span>
                          </span>
                        </td>
                        <td ui-table-cell class="py-3.5">
                          <div class="space-y-0.5">
                            <div class="flex items-center gap-1.5 text-xs font-medium">
                              <lucide-icon [img]="Clock" class="text-muted-foreground size-3.5 shrink-0" />
                              <span class="text-foreground">{{ secret.schedule.interval }}</span>
                            </div>
                            @if (secret.schedule.lambdaArn) {
                              <p
                                class="text-muted-foreground max-w-[160px] truncate font-mono text-xs"
                                [attr.title]="secret.schedule.lambdaArn"
                              >
                                λ {{ lambdaShort(secret.schedule.lambdaArn) }}
                              </p>
                            } @else {
                              <p class="text-muted-foreground font-mono text-xs">Manual trigger</p>
                            }
                          </div>
                        </td>
                        <td ui-table-cell class="py-3.5">
                          <div class="space-y-0.5 text-xs">
                            <div class="text-foreground font-medium">
                              {{ secret.nextRotationDue }}
                              @if (secret.status === 'due-soon') {
                                <span class="text-warning font-semibold"> ({{ secret.dueRelative }})</span>
                              } @else {
                                <span class="text-muted-foreground"> ({{ secret.dueRelative }})</span>
                              }
                            </div>
                            <p class="text-muted-foreground text-xs">
                              Last: <span class="font-mono">{{ secret.lastRotated }}</span>
                            </p>
                          </div>
                        </td>
                        <td ui-table-cell class="py-3.5">
                          <div class="space-y-1">
                            @if (secret.status === 'healthy') {
                              <span ui-badge variant="success" class="gap-1 text-xs">
                                <span class="bg-success size-1.5 animate-pulse rounded-full"></span>
                                <span>Automated · Healthy</span>
                              </span>
                            } @else if (secret.status === 'due-soon') {
                              <span ui-badge variant="warning" class="gap-1 text-xs">
                                <lucide-icon [img]="Clock" class="size-3" />
                                <span>Rotation Due Soon</span>
                              </span>
                            } @else if (secret.status === 'manual-needed') {
                              <span ui-badge variant="warning" class="gap-1 text-xs">
                                <lucide-icon [img]="AlertTriangle" class="size-3" />
                                <span>Manual Rotation Needed</span>
                              </span>
                            } @else if (secret.status === 'paused') {
                              <span ui-badge variant="secondary" class="gap-1 text-xs">
                                <lucide-icon [img]="Pause" class="size-3" />
                                <span>Paused</span>
                              </span>
                            } @else {
                              <span ui-badge variant="outline" class="gap-1 text-xs">
                                <lucide-icon [img]="RefreshCw" class="size-3 animate-spin" />
                                <span>Rotating</span>
                              </span>
                            }
                            <div class="text-muted-foreground font-mono text-xs">{{ secret.currentVersion }}</div>
                          </div>
                        </td>
                        <td ui-table-cell class="py-3.5 text-right">
                          <ui-dropdown-menu>
                            <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon" class="size-8">
                              <lucide-icon [img]="MoreHorizontal" class="size-4" />
                              <span class="sr-only">Open actions menu</span>
                            </button>
                            <ui-dropdown-menu-content align="end" class="w-56">
                              <ui-dropdown-menu-label class="text-xs">Rotation Controls</ui-dropdown-menu-label>
                              <ui-dropdown-menu-separator />
                              <ui-dropdown-menu-item (select)="openInstantRotate(secret)">
                                <lucide-icon [img]="Play" class="text-success mr-2 size-4" />
                                <span>Rotate Now (Zero Downtime)</span>
                              </ui-dropdown-menu-item>
                              <ui-dropdown-menu-item (select)="openScheduleModal(secret)">
                                <lucide-icon [img]="Pencil" class="mr-2 size-4" />
                                <span>Edit Schedule</span>
                              </ui-dropdown-menu-item>
                              <ui-dropdown-menu-item (select)="openLogsModal(secret)">
                                <lucide-icon [img]="Terminal" class="mr-2 size-4" />
                                <span>View Rotation Lambda Log</span>
                              </ui-dropdown-menu-item>
                              <ui-dropdown-menu-separator />
                              <ui-dropdown-menu-item (select)="togglePause(secret)">
                                @if (secret.status !== 'paused') {
                                  <lucide-icon [img]="Pause" class="mr-2 size-4" />
                                  <span>Pause Auto-Rotation</span>
                                } @else {
                                  <lucide-icon [img]="Play" class="text-success mr-2 size-4" />
                                  <span>Resume Auto-Rotation</span>
                                }
                              </ui-dropdown-menu-item>
                            </ui-dropdown-menu-content>
                          </ui-dropdown-menu>
                        </td>
                      </tr>
                    }
                    @if (filteredSecrets().length === 0) {
                      <tr ui-table-row>
                        <td ui-table-cell [attr.colspan]="6" class="text-muted-foreground h-32 text-center text-sm">
                          No secrets found matching the selected filter criteria.
                        </td>
                      </tr>
                    }
                  </tbody>
                </table>
              </div>
            </div>
          </div>
    
          <!-- Instant Rotate Dialog (Zero-Downtime Confirmation) -->
          <ui-dialog [open]="isRotateModalOpen()" (openChange)="isRotateModalOpen.set($event)">
            <ui-dialog-content class="sm:max-w-xl">
              <div ui-dialog-header>
                <div class="flex items-center gap-2">
                  <div class="bg-success/10 text-success rounded-lg p-2">
                    <lucide-icon [img]="RefreshCw" class="size-5" />
                  </div>
                  <div>
                    <h2 ui-dialog-title>Trigger Zero-Downtime Rotation</h2>
                    <p ui-dialog-description class="text-xs">
                      AWS Secrets Manager 4-step staging promotion for credential lifecycle safety.
                    </p>
                  </div>
                </div>
              </div>
    
              <div class="space-y-4 py-2">
                <div class="border-border bg-muted/50 space-y-2 rounded-lg border p-3">
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-muted-foreground">Target Secret:</span>
                    <span class="text-foreground font-mono font-semibold">{{ selectedSecretForRotate()?.name }}</span>
                  </div>
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-muted-foreground">Current Active:</span>
                    <span ui-badge variant="outline" class="font-mono text-xs">
                      {{ selectedSecretForRotate()?.currentVersion }}
                    </span>
                  </div>
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-muted-foreground">Staging Target:</span>
                    <span ui-badge variant="info" class="font-mono text-xs">
                      {{ selectedSecretForRotate()?.stagingVersion || 'v2.5 (AWSPENDING)' }}
                    </span>
                  </div>
                </div>
    
                <div class="space-y-2">
                  <span class="text-foreground text-xs font-semibold tracking-wider uppercase">
                    Dual-Version Execution Workflow
                  </span>
                  <div class="border-border bg-card space-y-2.5 rounded-lg border p-3.5 text-xs">
                    <div class="flex items-start gap-2.5">
                      <div
                        class="bg-primary text-primary-foreground flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
                      >
                        1
                      </div>
                      <div class="space-y-0.5">
                        <p class="text-foreground font-semibold">createSecret (Staging)</p>
                        <p class="text-muted-foreground text-xs">
                          Generates 32-byte cryptographic entropy and provisions version
                          <code class="font-mono font-semibold">AWSPENDING</code>.
                        </p>
                      </div>
                    </div>
                    <div class="flex items-start gap-2.5">
                      <div
                        class="bg-primary text-primary-foreground flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
                      >
                        2
                      </div>
                      <div class="space-y-0.5">
                        <p class="text-foreground font-semibold">setSecret (Dual-Authentication)</p>
                        <p class="text-muted-foreground text-xs">
                          Updates target resource (Postgres / API / KMS) so both Version A and Version B remain valid.
                        </p>
                      </div>
                    </div>
                    <div class="flex items-start gap-2.5">
                      <div
                        class="bg-primary text-primary-foreground flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
                      >
                        3
                      </div>
                      <div class="space-y-0.5">
                        <p class="text-foreground font-semibold">testSecret (Handshake Verification)</p>
                        <p class="text-muted-foreground text-xs">
                          Lambda executes synthetic database query using new credentials to verify validity.
                        </p>
                      </div>
                    </div>
                    <div class="flex items-start gap-2.5">
                      <div
                        class="bg-primary text-primary-foreground flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
                      >
                        4
                      </div>
                      <div class="space-y-0.5">
                        <p class="text-foreground font-semibold">finishSecret (Label Promotion)</p>
                        <p class="text-muted-foreground text-xs">
                          Moves <code class="font-mono font-semibold">AWSCURRENT</code> to Version B and demotes old version
                          to <code class="font-mono font-semibold">AWSPREVIOUS</code>.
                        </p>
                      </div>
                    </div>
                  </div>
                </div>
    
                <div
                  class="border-success/20 bg-success/5 text-success flex items-center gap-2.5 rounded-lg border p-3 text-xs"
                >
                  <lucide-icon [img]="ShieldCheck" class="text-success size-5 shrink-0" />
                  <span>
                    <strong>Zero-Downtime Guarantee:</strong> Existing connections continue using Version A until Version B
                    is 100% verified. No dropped TCP sockets or authentication spikes.
                  </span>
                </div>
              </div>
    
              <div ui-dialog-footer>
                <button
                  ui-button
                  variant="outline"
                  [disabled]="isRotatingInProgress()"
                  (click)="isRotateModalOpen.set(false)"
                >
                  Cancel
                </button>
                <button ui-button [disabled]="isRotatingInProgress()" class="gap-1.5" (click)="executeRotation()">
                  @if (isRotatingInProgress()) {
                    <lucide-icon [img]="RefreshCw" class="size-4 animate-spin" />
                    <span>Executing Dual-Stage Rotation...</span>
                  } @else {
                    <lucide-icon [img]="Play" class="size-4" />
                    <span>Confirm & Rotate Immediately</span>
                  }
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
    
          <!-- Schedule / Edit Schedule Dialog -->
          <ui-dialog [open]="isScheduleModalOpen()" (openChange)="isScheduleModalOpen.set($event)">
            <ui-dialog-content class="sm:max-w-lg">
              <div ui-dialog-header>
                <h2 ui-dialog-title>{{ editingSecret() ? 'Edit Rotation Schedule' : 'Schedule Secret Rotation' }}</h2>
                <p ui-dialog-description class="text-xs">
                  Configure automated periodic rotation interval and AWS Lambda rotation handler.
                </p>
              </div>
    
              <div class="space-y-4 py-2">
                <div class="space-y-1.5">
                  <label for="angular-secret-name" class="text-foreground text-xs font-medium">Secret Name / Path</label>
                  <ui-input
                    id="angular-secret-name"
                    [value]="formName()"
                    (valueChange)="formName.set($event)"
                    placeholder="e.g. prod/postgres/replica-credentials"
                    class="text-xs"
                  />
                </div>
    
                <div class="grid grid-cols-2 gap-3">
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">Secret Type</label>
                    <ui-select [value]="formType()" (valueChange)="setFormType($event)">
                      <button ui-select-trigger class="text-xs">
                        <ui-select-value placeholder="Secret Type" />
                      </button>
                      <ui-select-content>
                        @for (t of secretTypes; track t) {
                          <ui-select-item [value]="t">{{ t }}</ui-select-item>
                        }
                      </ui-select-content>
                    </ui-select>
                  </div>
    
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">Rotation Interval</label>
                    <ui-select [value]="formInterval()" (valueChange)="formInterval.set($event)">
                      <button ui-select-trigger class="text-xs">
                        <ui-select-value placeholder="Interval" />
                      </button>
                      <ui-select-content>
                        @for (iv of intervals; track iv) {
                          <ui-select-item [value]="iv">{{ iv }}</ui-select-item>
                        }
                      </ui-select-content>
                    </ui-select>
                  </div>
                </div>
    
                <div class="space-y-1.5">
                  <label for="angular-secret-lambda" class="text-foreground text-xs font-medium">
                    Rotation Lambda Function ARN
                  </label>
                  <ui-input
                    id="angular-secret-lambda"
                    [value]="formLambdaArn()"
                    (valueChange)="formLambdaArn.set($event)"
                    placeholder="arn:aws:lambda:us-east-1:182938491029:function:RotateSecret"
                    class="font-mono text-xs"
                  />
                </div>
    
                <div class="border-border bg-muted/40 flex items-center justify-between rounded-lg border p-3">
                  <div class="space-y-0.5">
                    <p class="text-foreground text-xs font-medium">Zero-Downtime Dual Versioning</p>
                    <p class="text-muted-foreground text-xs">Verify staging version before promoting AWSCURRENT</p>
                  </div>
                  <span ui-badge variant="success" class="text-xs">Active</span>
                </div>
              </div>
    
              <div ui-dialog-footer>
                <button ui-button variant="outline" (click)="isScheduleModalOpen.set(false)">Cancel</button>
                <button ui-button [disabled]="!formName().trim()" (click)="saveSchedule()">
                  {{ editingSecret() ? 'Save Changes' : 'Create Rotation Schedule' }}
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
    
          <!-- View Rotation Lambda Log Dialog -->
          <ui-dialog [open]="isLogsModalOpen()" (openChange)="isLogsModalOpen.set($event)">
            <ui-dialog-content class="sm:max-w-2xl">
              <div ui-dialog-header>
                <div class="flex items-center gap-2">
                  <lucide-icon [img]="Terminal" class="text-primary size-5" />
                  <h2 ui-dialog-title class="font-mono text-base">{{ selectedSecretForLogs()?.name }}</h2>
                </div>
                <p ui-dialog-description class="font-mono text-xs">
                  Lambda Handler: {{ selectedSecretForLogs()?.schedule?.lambdaArn || 'arn:aws:lambda:...:RotateDefault' }}
                </p>
              </div>
    
              <div class="space-y-3 py-1">
                <div
                  class="border-border bg-muted/60 flex items-center justify-between rounded-md border px-3 py-2 text-xs"
                >
                  <div class="flex items-center gap-2">
                    <span class="text-muted-foreground">Last Invocation:</span>
                    <span class="text-foreground font-mono font-medium">{{ selectedSecretForLogs()?.lastRotated }}</span>
                  </div>
                  <div class="flex items-center gap-2">
                    <span class="text-muted-foreground">Status:</span>
                    <span ui-badge variant="success" class="font-mono text-xs">200 OK - SUCCESS</span>
                  </div>
                </div>
    
                <div
                  class="border-border text-success space-y-1.5 overflow-x-auto rounded-lg border bg-black/90 p-4 font-mono text-xs dark:bg-black"
                >
                  <p class="text-muted-foreground">START RequestId: 4f8b91a2-631c-4b89-9801-ec8492019a Version: $LATEST</p>
                  <p class="text-success">
                    [INFO] Step 1/4 createSecret: Generating 32-byte cryptographic entropy for AWSPENDING...
                  </p>
                  <p class="text-success">
                    [INFO] Step 2/4 setSecret: Applying shadow credentials to destination cluster. Dual-auth enabled.
                  </p>
                  <p class="text-success">
                    [INFO] Step 3/4 testSecret: Executing test query from staging connection pool -&gt; Verified (6.4ms)
                  </p>
                  <p class="text-success">
                    [INFO] Step 4/4 finishSecret: Swapped AWSCURRENT label to staging version. Previous marked AWSPREVIOUS.
                  </p>
                  <p class="text-muted-foreground">END RequestId: 4f8b91a2-631c-4b89-9801-ec8492019a</p>
                  <p class="text-muted-foreground">
                    REPORT RequestId: 4f8b91a2-631c-4b89-9801-ec8492019a Duration: 342.15 ms Billed Duration: 343 ms
                  </p>
                </div>
              </div>
    
              <div ui-dialog-footer>
                <button ui-button variant="outline" (click)="isLogsModalOpen.set(false)">Close Logs</button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiSecretsRotationSchedulerComponent {
      protected readonly AlertTriangle = AlertTriangle
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly Database = Database
      protected readonly Globe = Globe
      protected readonly Info = Info
      protected readonly KeyRound = KeyRound
      protected readonly Lock = Lock
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly Pause = Pause
      protected readonly Pencil = Pencil
      protected readonly Play = Play
      protected readonly Plus = Plus
      protected readonly RefreshCw = RefreshCw
      protected readonly Search = Search
      protected readonly Server = Server
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Terminal = Terminal
      protected readonly X = X
    
      protected readonly secretTypes = SECRET_TYPES
      protected readonly intervals = INTERVALS
    
      @Input() set initialSecrets(v: ManagedSecret[] | undefined) {
        this.secrets.set((v ?? defaultSecrets).map((s) => ({ ...s, schedule: { ...s.schedule } })))
      }
      @Input() set initialStats(v: SecretsRotationStats | undefined) {
        this.stats.set({ ...(v ?? defaultStats) })
      }
      @Input('class') className?: string
    
      readonly secrets = signal<ManagedSecret[]>(defaultSecrets.map((s) => ({ ...s, schedule: { ...s.schedule } })))
      readonly stats = signal<SecretsRotationStats>({ ...defaultStats })
      readonly search = signal('')
      readonly statusFilter = signal('all')
      readonly bannerMessage = signal<{ type: 'success' | 'info'; text: string } | null>(null)
      readonly rotatingSecretId = signal<string | null>(null)
      readonly copiedArn = signal<string | null>(null)
    
      readonly isRotateModalOpen = signal(false)
      readonly selectedSecretForRotate = signal<ManagedSecret | null>(null)
      readonly isRotatingInProgress = signal(false)
    
      readonly isScheduleModalOpen = signal(false)
      readonly editingSecret = signal<ManagedSecret | null>(null)
      readonly formName = signal('')
      readonly formArn = signal('')
      readonly formType = signal<SecretType>('Postgres DB')
      readonly formInterval = signal('Every 30 Days')
      readonly formLambdaArn = signal('')
      readonly formZeroDowntime = signal(true)
    
      readonly isLogsModalOpen = signal(false)
      readonly selectedSecretForLogs = signal<ManagedSecret | null>(null)
    
      readonly filteredSecrets = computed(() => {
        const q = this.search().trim().toLowerCase()
        const status = this.statusFilter()
        return this.secrets().filter((sec) => {
          const matchesStatus = status === 'all' || sec.status === status
          const matchesSearch =
            !q ||
            sec.name.toLowerCase().includes(q) ||
            sec.arn.toLowerCase().includes(q) ||
            sec.type.toLowerCase().includes(q)
          return matchesStatus && matchesSearch
        })
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      typeVariant(type: SecretType): 'default' | 'secondary' | 'outline' | 'info' | 'warning' {
        return typeBadgeVariant(type)
      }
    
      lambdaShort(arn: string): string {
        return arn.split(':function:')[1] || 'Lambda'
      }
    
      setFormType(v: string): void {
        this.formType.set(v as SecretType)
      }
    
      copyToClipboard(text: string, id: string): void {
        try {
          navigator.clipboard.writeText(text)
        } catch {
          // Clipboard unavailable in this context.
        }
        this.copiedArn.set(id)
        setTimeout(() => {
          if (this.copiedArn() === id) this.copiedArn.set(null)
        }, 2000)
      }
    
      openInstantRotate(secret: ManagedSecret): void {
        this.selectedSecretForRotate.set(secret)
        this.isRotateModalOpen.set(true)
      }
    
      executeRotation(): void {
        const target = this.selectedSecretForRotate()
        if (!target) return
        this.isRotatingInProgress.set(true)
        const targetId = target.id
        const targetName = target.name
        this.rotatingSecretId.set(targetId)
        setTimeout(() => {
          this.isRotatingInProgress.set(false)
          this.isRotateModalOpen.set(false)
          this.rotatingSecretId.set(null)
          this.secrets.update((prev) =>
            prev.map((s) =>
              s.id === targetId
                ? {
                    ...s,
                    lastRotated: 'Just now',
                    nextRotationDue: 'Sep 20, 2026',
                    dueRelative: 'in 30 days',
                    status: 'healthy' as RotationStatus,
                    currentVersion: 'v2.5 (AWSCURRENT)',
                    stagingVersion: undefined,
                    lastLogSnippet:
                      'Zero-downtime rotation completed successfully. Version B verified, promoted to AWSCURRENT, and staged safely.',
                  }
                : s,
            ),
          )
          this.bannerMessage.set({
            type: 'success',
            text: `Zero-downtime rotation successfully completed for "${targetName}". Credentials validated with 0 dropped sockets.`,
          })
        }, 1400)
      }
    
      openScheduleModal(secret?: ManagedSecret): void {
        if (secret) {
          this.editingSecret.set(secret)
          this.formName.set(secret.name)
          this.formArn.set(secret.arn)
          this.formType.set(secret.type)
          this.formInterval.set(secret.schedule.interval)
          this.formLambdaArn.set(secret.schedule.lambdaArn || '')
          this.formZeroDowntime.set(secret.schedule.zeroDowntimeVerified)
        } else {
          this.editingSecret.set(null)
          this.formName.set('')
          this.formArn.set('')
          this.formType.set('Postgres DB')
          this.formInterval.set('Every 30 Days')
          this.formLambdaArn.set('arn:aws:lambda:us-east-1:182938491029:function:RotateSecretHandler')
          this.formZeroDowntime.set(true)
        }
        this.isScheduleModalOpen.set(true)
      }
    
      saveSchedule(): void {
        if (!this.formName().trim()) return
        const editing = this.editingSecret()
        if (editing) {
          const name = this.formName().trim()
          const arn = this.formArn().trim()
          this.secrets.update((prev) =>
            prev.map((s) =>
              s.id === editing.id
                ? {
                    ...s,
                    name,
                    arn: arn || s.arn,
                    type: this.formType(),
                    schedule: {
                      ...s.schedule,
                      interval: this.formInterval(),
                      lambdaArn: this.formLambdaArn().trim() || undefined,
                      zeroDowntimeVerified: this.formZeroDowntime(),
                    },
                  }
                : s,
            ),
          )
          this.bannerMessage.set({ type: 'info', text: `Rotation schedule for "${name}" updated successfully.` })
        } else {
          const name = this.formName().trim()
          const newSecret: ManagedSecret = {
            id: `sec-${Date.now()}`,
            name,
            arn:
              this.formArn().trim() || `arn:aws:secretsmanager:us-east-1:182938491029:secret:${name.toLowerCase()}-pending`,
            type: this.formType(),
            schedule: {
              interval: this.formInterval(),
              daysInterval: this.formInterval().includes('7') ? 7 : this.formInterval().includes('90') ? 90 : 30,
              lambdaArn: this.formLambdaArn().trim() || undefined,
              zeroDowntimeVerified: this.formZeroDowntime(),
            },
            lastRotated: 'Never (Initial)',
            nextRotationDue: 'Pending First Run',
            dueRelative: 'in 2 hours',
            status: 'healthy',
            currentVersion: 'v1.0 (AWSCURRENT)',
            stagingVersion: undefined,
            lastLogSnippet: 'Initial rotation schedule registered with automated zero-downtime dual-version handshake.',
          }
          this.secrets.update((prev) => [newSecret, ...prev])
          this.bannerMessage.set({ type: 'success', text: `New secret rotation schedule registered for "${name}".` })
        }
        this.isScheduleModalOpen.set(false)
      }
    
      togglePause(secret: ManagedSecret): void {
        const nextStatus: RotationStatus = secret.status === 'paused' ? 'healthy' : 'paused'
        this.secrets.update((prev) => prev.map((s) => (s.id === secret.id ? { ...s, status: nextStatus } : s)))
        this.bannerMessage.set({
          type: 'info',
          text: `Rotation schedule for "${secret.name}" ${nextStatus === 'paused' ? 'paused' : 'resumed'}.`,
        })
      }
    
      openLogsModal(secret: ManagedSecret): void {
        this.selectedSecretForLogs.set(secret)
        this.isLogsModalOpen.set(true)
      }
    }
    
  • src/app/components/blocks/secrets-rotation-scheduler/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/secrets-rotation-scheduler.json