Menu

Change language

GitHub repo

Access Request Workflow

blocksecurity

Just-In-Time (JIT) IAM temporary privilege escalation request modal and approval queue: governance metrics cards, elevated session countdown timers, approval form dialog, and active session manager with instant revoke and audit logging.

Installation

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

Variants

Loading interactive previews…

Schema

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

AccessRequester
interface AccessRequester {
  name: string
  email: string
  team: string
  avatar: string
}
AccessRequest
interface AccessRequest {
  id: string
  requester: AccessRequester
  targetRole: string
  system: string
  duration: string
  timeLeftLabel: string
  secondsLeft: number
  justification: string
  ticketId: string
  approver: string
  status: AccessRequestStatus
  createdAt: string
  expiresAt: string
}

Files installed (2)

  • src/app/components/blocks/access-request-workflow/access-request-workflow.component.ts36.7 kB
    // Just-In-Time (JIT) IAM temporary privilege escalation: governance metric cards,
    // elevated session countdown timers, request form dialog, and an approval queue with
    // instant revoke and audit actions. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      Check,
      Clock,
      Copy,
      FileText,
      History,
      Hourglass,
      KeyRound,
      Lock,
      LucideAngularModule,
      MoreHorizontal,
      Plus,
      Search,
      Send,
      ShieldAlert,
      ShieldCheck,
      UserCheck,
      X,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent, type BadgeVariant } 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'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export type AccessRequestStatus = 'active' | 'pending' | 'expired' | 'rejected'
    export type AccessRequestTab = 'all' | 'pending' | 'active' | 'expired'
    
    export interface AccessRequester {
      name: string
      email: string
      team: string
      avatar: string
    }
    
    export interface AccessRequest {
      id: string
      requester: AccessRequester
      targetRole: string
      system: string
      duration: string
      timeLeftLabel: string
      secondsLeft: number
      justification: string
      ticketId: string
      approver: string
      status: AccessRequestStatus
      createdAt: string
      expiresAt: string
    }
    
    const initialRequests: AccessRequest[] = [
      {
        id: 'req-1',
        requester: { name: 'Elena Rostova', email: '[email protected]', team: 'SecOps Team', avatar: 'ER' },
        targetRole: 'AWS Production Admin',
        system: 'AWS Cloud IAM (us-east-1)',
        duration: '4 Hours',
        timeLeftLabel: '2h 45m left',
        secondsLeft: 9918,
        justification: 'Investigating latency spike on payment gateway for incident #SEC-849',
        ticketId: 'SEC-849',
        approver: 'Marcus Vance',
        status: 'active',
        createdAt: '1h 15m ago',
        expiresAt: 'Today at 16:30',
      },
      {
        id: 'req-2',
        requester: { name: 'Marcus Vance', email: '[email protected]', team: 'DevOps & Infra', avatar: 'MV' },
        targetRole: 'Production DB Read-Only',
        system: 'PostgreSQL Aurora Primary',
        duration: '1 Hour (Emergency)',
        timeLeftLabel: '48m left',
        secondsLeft: 2892,
        justification: 'Emergency replica query check during PostgreSQL failover testing',
        ticketId: 'INC-2044',
        approver: 'Sarah Chen',
        status: 'active',
        createdAt: '12m ago',
        expiresAt: 'Today at 14:15',
      },
      {
        id: 'req-3',
        requester: { name: 'Sarah Chen', email: '[email protected]', team: 'Core Platform', avatar: 'SC' },
        targetRole: 'Customer PII Decryption',
        system: 'Customer Data Vault (KMS)',
        duration: '8 Hours (1 Shift)',
        timeLeftLabel: 'Pending Approval',
        secondsLeft: 28800,
        justification: 'GDPR user deletion verification request #COMP-1102',
        ticketId: 'COMP-1102',
        approver: 'Marcus Vance',
        status: 'pending',
        createdAt: '10m ago',
        expiresAt: 'Awaiting approval',
      },
      {
        id: 'req-4',
        requester: { name: 'David Kim', email: '[email protected]', team: 'Fintech Systems', avatar: 'DK' },
        targetRole: 'Stripe Dashboard Full Access',
        system: 'Stripe Merchant Gateway',
        duration: '1 Hour (Emergency)',
        timeLeftLabel: 'Pending Approval',
        secondsLeft: 3600,
        justification: 'Refunding disputed charges for compromised merchant accounts',
        ticketId: 'FIN-582',
        approver: 'Marcus Vance',
        status: 'pending',
        createdAt: '25m ago',
        expiresAt: 'Awaiting approval',
      },
      {
        id: 'req-5',
        requester: { name: 'Alex Morgan', email: '[email protected]', team: 'Data Platform', avatar: 'AM' },
        targetRole: 'Production DB Read-Only',
        system: 'Snowflake Core Analytics',
        duration: '4 Hours',
        timeLeftLabel: 'Expired',
        secondsLeft: 0,
        justification: 'Quarterly compliance metrics export and audit schema migration verification',
        ticketId: 'AUD-309',
        approver: 'Elena Rostova',
        status: 'expired',
        createdAt: 'Yesterday',
        expiresAt: 'Expired at 18:00',
      },
    ]
    
    function formatSecondsValue(seconds: number): string {
      if (seconds <= 0) return '00:00:00'
      const h = Math.floor(seconds / 3600)
      const m = Math.floor((seconds % 3600) / 60)
      const s = seconds % 60
      return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
    }
    
    function timeLeftLabelFor(seconds: number): string {
      const h = Math.floor(seconds / 3600)
      const m = Math.floor((seconds % 3600) / 60)
      return h > 0 ? `${h}h ${m}m left` : `${m}m left`
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-access-request-workflow, [ui-access-request-workflow]',
      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,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        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,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="access-request-workflow" [class]="rootClass">
          <!-- Header -->
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <div class="flex items-center gap-2">
                <div class="bg-primary/10 text-primary flex size-9 items-center justify-center rounded-lg">
                  <lucide-icon [img]="KeyRound" class="size-5" />
                </div>
                <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">{{ title }}</h1>
              </div>
              <p class="text-muted-foreground mt-1 text-sm">{{ subtitle }}</p>
            </div>
    
            <button ui-button class="shrink-0 gap-2 shadow-xs" (click)="isDialogOpen.set(true)">
              <lucide-icon [img]="Plus" class="size-4" />
              Request New Access
            </button>
          </div>
    
          <!-- Request New Access dialog -->
          <ui-dialog [open]="isDialogOpen()" (openChange)="isDialogOpen.set($event)">
            <ui-dialog-content class="sm:max-w-lg">
              <div ui-dialog-header>
                <h2 ui-dialog-title class="flex items-center gap-2">
                  <lucide-icon [img]="ShieldAlert" class="text-primary size-5" />
                  Request Temporary Access
                </h2>
                <p ui-dialog-description>
                  Submit a Just-In-Time (JIT) access request with duration and business justification.
                </p>
              </div>
    
              <form class="space-y-4 py-2" (submit)="handleCreateRequest($event)">
                <div class="space-y-1.5">
                  <label class="text-foreground text-xs font-medium">Target System / Role</label>
                  <ui-select [value]="formRole()" (valueChange)="formRole.set($event)">
                    <button ui-select-trigger class="w-full">
                      <ui-select-value placeholder="Select target role" />
                    </button>
                    <ui-select-content>
                      <ui-select-item value="Production DB Read-Only">Production DB Read-Only</ui-select-item>
                      <ui-select-item value="AWS Production Admin">AWS Production Admin</ui-select-item>
                      <ui-select-item value="Stripe Dashboard Full Access">Stripe Dashboard Full Access</ui-select-item>
                      <ui-select-item value="Customer PII Decryption">Customer PII Decryption</ui-select-item>
                    </ui-select-content>
                  </ui-select>
                </div>
    
                <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">Access Duration</label>
                    <ui-select [value]="formDuration()" (valueChange)="formDuration.set($event)">
                      <button ui-select-trigger class="w-full">
                        <ui-select-value placeholder="Select duration" />
                      </button>
                      <ui-select-content>
                        <ui-select-item value="1 Hour (Emergency)">1 Hour (Emergency)</ui-select-item>
                        <ui-select-item value="4 Hours">4 Hours</ui-select-item>
                        <ui-select-item value="8 Hours (1 Shift)">8 Hours (1 Shift)</ui-select-item>
                        <ui-select-item value="24 Hours max">24 Hours max</ui-select-item>
                      </ui-select-content>
                    </ui-select>
                  </div>
    
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">Approver / Team</label>
                    <ui-select [value]="formApprover()" (valueChange)="formApprover.set($event)">
                      <button ui-select-trigger class="w-full">
                        <ui-select-value placeholder="Select approver" />
                      </button>
                      <ui-select-content>
                        <ui-select-item value="Security Lead: Marcus Vance">Security Lead: Marcus Vance</ui-select-item>
                        <ui-select-item value="Infrastructure Team: Sarah Chen">
                          Infrastructure Team: Sarah Chen
                        </ui-select-item>
                        <ui-select-item value="Compliance Officer: Elena Rostova">
                          Compliance Officer: Elena Rostova
                        </ui-select-item>
                        <ui-select-item value="On-Call Lead: David Kim">On-Call Lead: David Kim</ui-select-item>
                      </ui-select-content>
                    </ui-select>
                  </div>
                </div>
    
                <div class="space-y-1.5">
                  <label class="text-foreground text-xs font-medium">Business Justification</label>
                  <ui-textarea
                    [value]="formJustification()"
                    (valueChange)="formJustification.set($event)"
                    placeholder="Investigating latency spike on payment gateway for incident #SEC-849"
                    [rows]="3"
                    class="resize-none text-xs"
                  />
                  <p class="text-muted-foreground text-xs">
                    All elevated actions are recorded and signed in the immutable IAM audit stream.
                  </p>
                </div>
    
                <div ui-dialog-footer class="pt-2">
                  <button ui-button type="button" variant="outline" (click)="isDialogOpen.set(false)">Cancel</button>
                  <button ui-button type="submit" class="gap-2">
                    <lucide-icon [img]="Send" class="size-4" />
                    Submit Request
                  </button>
                </div>
              </form>
            </ui-dialog-content>
          </ui-dialog>
    
          <!-- 4 Access Governance KPI Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="border-border/80 relative overflow-hidden 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-xs font-medium">Active Elevated Sessions</h3>
                <div class="bg-success/10 text-success rounded-md p-1.5">
                  <lucide-icon [img]="ShieldAlert" class="size-4" />
                </div>
              </div>
              <div ui-card-content>
                <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">{{ activeCount() }}</div>
                <p class="text-muted-foreground mt-1 text-xs">Expiring within 8h</p>
              </div>
            </div>
    
            <div ui-card class="border-border/80 relative overflow-hidden 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-xs font-medium">Pending Approvals</h3>
                <div class="bg-warning/10 text-warning rounded-md p-1.5">
                  <lucide-icon [img]="Clock" class="size-4" />
                </div>
              </div>
              <div ui-card-content>
                <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">{{ pendingCount() }}</div>
                <p class="text-muted-foreground mt-1 text-xs">Requires security lead review</p>
              </div>
            </div>
    
            <div ui-card class="border-border/80 relative overflow-hidden 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-xs font-medium">Requests Expiring Today</h3>
                <div class="bg-chart-1/10 text-chart-1 rounded-md p-1.5">
                  <lucide-icon [img]="Hourglass" class="size-4" />
                </div>
              </div>
              <div ui-card-content>
                <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">{{ expiringCount() }}</div>
                <p class="text-muted-foreground mt-1 text-xs">Auto-revocation armed</p>
              </div>
            </div>
    
            <div ui-card class="border-border/80 relative overflow-hidden 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-xs font-medium">30-Day Total Requests</h3>
                <div class="bg-primary/10 text-primary rounded-md p-1.5">
                  <lucide-icon [img]="History" class="size-4" />
                </div>
              </div>
              <div ui-card-content>
                <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">{{ total30Day() }}</div>
                <p class="text-muted-foreground mt-1 text-xs">98.2% compliance audit pass</p>
              </div>
            </div>
          </div>
    
          <!-- Pending & Active Requests Table Card -->
          <div ui-card class="border-border/80 shadow-xs">
            <div ui-card-header class="border-border/60 border-b pb-4">
              <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
                <div>
                  <h3 ui-card-title class="text-foreground text-base font-semibold">Privilege Escalation Queue</h3>
                  <p ui-card-description class="text-muted-foreground mt-0.5 text-xs">
                    Review, approve, or reject active and pending privilege requests.
                  </p>
                </div>
    
                <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
                  <div class="relative w-full sm:w-60">
                    <lucide-icon
                      [img]="Search"
                      class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                    />
                    <ui-input
                      [value]="searchQuery()"
                      (valueChange)="searchQuery.set($event)"
                      placeholder="Search requester, role, ticket..."
                      class="h-8 pl-8 text-xs"
                    />
                  </div>
    
                  <div class="border-border bg-muted/40 flex items-center rounded-lg border p-0.5">
                    <button type="button" [class]="tabClass('all')" (click)="setSelectedTab('all')">
                      All ({{ requests().length }})
                    </button>
                    <button type="button" [class]="tabClass('pending')" (click)="setSelectedTab('pending')">
                      Pending ({{ pendingCount() }})
                    </button>
                    <button type="button" [class]="tabClass('active')" (click)="setSelectedTab('active')">
                      Active ({{ activeCount() }})
                    </button>
                    <button type="button" [class]="tabClass('expired')" (click)="setSelectedTab('expired')">
                      Expired ({{ expiredTabCount() }})
                    </button>
                  </div>
                </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="w-[220px] text-xs font-medium">Requester</th>
                      <th ui-table-head class="w-[200px] text-xs font-medium">Target Role & System</th>
                      <th ui-table-head class="w-[170px] text-xs font-medium">Duration & Timer</th>
                      <th ui-table-head class="min-w-[260px] text-xs font-medium">Business Justification</th>
                      <th ui-table-head class="w-[180px] text-xs font-medium">Status</th>
                      <th ui-table-head class="w-[160px] text-right text-xs font-medium">Actions</th>
                    </tr>
                  </thead>
                  <tbody ui-table-body>
                    @if (filteredRequests().length === 0) {
                      <tr ui-table-row>
                        <td ui-table-cell [attr.colspan]="6" class="text-muted-foreground h-32 text-center text-xs">
                          No access requests match your filter.
                        </td>
                      </tr>
                    } @else {
                      @for (req of filteredRequests(); track req.id) {
                        <tr ui-table-row class="hover:bg-muted/30 transition-colors">
                          <td ui-table-cell>
                            <div class="flex items-center gap-3">
                              <ui-avatar class="border-border/60 size-8 border">
                                <ui-avatar-fallback class="bg-muted text-foreground text-xs font-semibold">
                                  {{ req.requester.avatar }}
                                </ui-avatar-fallback>
                              </ui-avatar>
                              <div class="min-w-0">
                                <div class="text-foreground truncate text-xs font-medium">{{ req.requester.name }}</div>
                                <div class="text-muted-foreground truncate text-xs">{{ req.requester.email }}</div>
                              </div>
                            </div>
                          </td>
    
                          <td ui-table-cell>
                            <div class="flex flex-col gap-1">
                              <span ui-badge [variant]="roleBadgeVariant(req.targetRole)" class="w-fit text-xs font-medium">
                                {{ req.targetRole }}
                              </span>
                              <span class="text-muted-foreground truncate text-xs">{{ req.system }}</span>
                            </div>
                          </td>
    
                          <td ui-table-cell>
                            <div class="flex flex-col gap-1">
                              <span class="text-foreground text-xs font-medium">{{ req.duration }}</span>
                              @if (req.status === 'active') {
                                <div class="text-success flex items-center gap-1.5 font-mono text-xs tabular-nums">
                                  <lucide-icon [img]="Clock" class="size-3 shrink-0" />
                                  <span>{{ formatSeconds(req.secondsLeft) }}</span>
                                </div>
                              } @else if (req.status === 'pending') {
                                <div class="text-muted-foreground flex items-center gap-1.5 font-mono text-xs tabular-nums">
                                  <lucide-icon [img]="Clock" class="size-3 shrink-0" />
                                  <span>Awaiting start</span>
                                </div>
                              } @else {
                                <div class="text-muted-foreground flex items-center gap-1.5 font-mono text-xs tabular-nums">
                                  <lucide-icon [img]="Clock" class="size-3 shrink-0" />
                                  <span>Session ended</span>
                                </div>
                              }
                            </div>
                          </td>
    
                          <td ui-table-cell>
                            <div class="flex flex-col gap-1">
                              <div class="flex items-center gap-1.5">
                                <span
                                  class="border-border bg-muted/60 text-foreground inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-xs font-medium"
                                >
                                  #{{ req.ticketId }}
                                </span>
                                <span class="text-muted-foreground text-xs">
                                  Approver: {{ shortApprover(req.approver) }}
                                </span>
                              </div>
                              <p class="text-muted-foreground line-clamp-2 text-xs" [attr.title]="req.justification">
                                {{ req.justification }}
                              </p>
                            </div>
                          </td>
    
                          <td ui-table-cell>
                            <div class="flex items-center">
                              @if (req.status === 'active') {
                                <span ui-badge variant="success" class="gap-1.5 text-xs font-medium">
                                  <span class="relative flex size-2">
                                    <span
                                      class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"
                                    ></span>
                                    <span class="bg-success relative inline-flex size-2 rounded-full"></span>
                                  </span>
                                  <span>Active - {{ req.timeLeftLabel }}</span>
                                </span>
                              } @else if (req.status === 'pending') {
                                <span ui-badge variant="warning" class="gap-1.5 text-xs font-medium">
                                  <span class="bg-warning size-2 animate-pulse rounded-full"></span>
                                  <span>Pending Approval</span>
                                </span>
                              } @else if (req.status === 'expired') {
                                <span
                                  ui-badge
                                  variant="secondary"
                                  class="text-muted-foreground gap-1.5 text-xs font-normal"
                                >
                                  <span class="bg-muted-foreground/50 size-2 rounded-full"></span>
                                  <span>Expired</span>
                                </span>
                              } @else {
                                <span ui-badge variant="destructive" class="gap-1.5 text-xs font-normal">
                                  <span class="bg-destructive size-2 rounded-full"></span>
                                  <span>Rejected</span>
                                </span>
                              }
                            </div>
                          </td>
    
                          <td ui-table-cell class="text-right">
                            <div class="flex items-center justify-end gap-1.5">
                              @if (req.status === 'pending') {
                                <button
                                  ui-button
                                  size="sm"
                                  class="h-7 gap-1 px-2.5 text-xs shadow-xs"
                                  (click)="handleApprove(req.id)"
                                >
                                  <lucide-icon [img]="Check" class="size-3.5" />
                                  Approve
                                </button>
                                <button
                                  ui-button
                                  size="sm"
                                  variant="outline"
                                  class="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 gap-1 px-2 text-xs"
                                  (click)="handleReject(req.id)"
                                >
                                  <lucide-icon [img]="X" class="size-3.5" />
                                  Reject
                                </button>
                              }
                              @if (req.status === 'active') {
                                <button
                                  ui-button
                                  size="sm"
                                  variant="outline"
                                  class="text-destructive border-destructive/30 hover:bg-destructive/10 hover:text-destructive h-7 gap-1 px-2 text-xs"
                                  (click)="handleRevoke(req.id)"
                                >
                                  <lucide-icon [img]="Lock" class="size-3.5" />
                                  Revoke
                                </button>
                                <ui-dropdown-menu>
                                  <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon" class="size-7">
                                    <lucide-icon [img]="MoreHorizontal" class="size-3.5" />
                                    <span class="sr-only">More actions</span>
                                  </button>
                                  <ui-dropdown-menu-content align="end" class="w-48">
                                    <div ui-dropdown-menu-label class="text-xs">Elevated Session</div>
                                    <ui-dropdown-menu-separator />
                                    <ui-dropdown-menu-item (select)="handleExtend(req.id)">
                                      <lucide-icon [img]="Clock" class="mr-2 size-3.5" />
                                      <span>Extend 30 Minutes</span>
                                    </ui-dropdown-menu-item>
                                    <ui-dropdown-menu-item (select)="handleCopyToken(req.id)">
                                      <lucide-icon [img]="Copy" class="mr-2 size-3.5" />
                                      <span>{{ copiedId() === req.id ? 'Copied Token!' : 'Copy Token' }}</span>
                                    </ui-dropdown-menu-item>
                                    <ui-dropdown-menu-separator />
                                    <ui-dropdown-menu-item
                                      class="text-destructive focus:text-destructive"
                                      (select)="handleRevoke(req.id)"
                                    >
                                      <lucide-icon [img]="ShieldAlert" class="mr-2 size-3.5" />
                                      <span>Revoke Immediately</span>
                                    </ui-dropdown-menu-item>
                                  </ui-dropdown-menu-content>
                                </ui-dropdown-menu>
                              }
                              @if (req.status !== 'pending' && req.status !== 'active') {
                                <button
                                  ui-button
                                  size="sm"
                                  variant="ghost"
                                  class="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
                                  (click)="handleReRequest(req.id)"
                                >
                                  Re-request
                                </button>
                                <ui-dropdown-menu>
                                  <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon" class="size-7">
                                    <lucide-icon [img]="MoreHorizontal" class="size-3.5" />
                                    <span class="sr-only">More actions</span>
                                  </button>
                                  <ui-dropdown-menu-content align="end" class="w-44">
                                    <ui-dropdown-menu-item (select)="handleReRequest(req.id)">
                                      <lucide-icon [img]="History" class="mr-2 size-3.5" />
                                      <span>Request Again</span>
                                    </ui-dropdown-menu-item>
                                    <ui-dropdown-menu-item>
                                      <lucide-icon [img]="FileText" class="mr-2 size-3.5" />
                                      <span>View Audit Log</span>
                                    </ui-dropdown-menu-item>
                                  </ui-dropdown-menu-content>
                                </ui-dropdown-menu>
                              }
                            </div>
                          </td>
                        </tr>
                      }
                    }
                  </tbody>
                </table>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiAccessRequestWorkflowComponent {
      protected readonly Check = Check
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly FileText = FileText
      protected readonly History = History
      protected readonly Hourglass = Hourglass
      protected readonly KeyRound = KeyRound
      protected readonly Lock = Lock
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly Plus = Plus
      protected readonly Search = Search
      protected readonly Send = Send
      protected readonly ShieldAlert = ShieldAlert
      protected readonly ShieldCheck = ShieldCheck
      protected readonly UserCheck = UserCheck
      protected readonly X = X
    
      @Input() title = 'Access Requests & Privilege Elevation'
      @Input() subtitle = 'Request temporary Just-In-Time production access with audit approval.'
      @Input('class') className?: string
    
      readonly requests = signal<AccessRequest[]>(initialRequests.map((r) => ({ ...r, requester: { ...r.requester } })))
    
      readonly selectedTab = signal<AccessRequestTab>('all')
      readonly searchQuery = signal('')
    
      readonly isDialogOpen = signal(false)
      readonly formRole = signal('AWS Production Admin')
      readonly formDuration = signal('4 Hours')
      readonly formApprover = signal('Security Lead: Marcus Vance')
      readonly formJustification = signal('Investigating latency spike on payment gateway for incident #SEC-849')
    
      readonly copiedId = signal<string | null>(null)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      constructor() {
        const destroyRef = inject(DestroyRef)
        const timer = setInterval(() => this.tick(), 1000)
        destroyRef.onDestroy(() => {
          clearInterval(timer)
          clearTimeout(this.copyTimer)
        })
      }
    
      readonly activeCount = computed(() => this.requests().filter((r) => r.status === 'active').length)
      readonly pendingCount = computed(() => this.requests().filter((r) => r.status === 'pending').length)
      readonly expiredTabCount = computed(
        () => this.requests().filter((r) => r.status === 'expired' || r.status === 'rejected').length,
      )
      readonly expiringCount = computed(
        () => this.requests().filter((r) => r.status === 'active' && r.secondsLeft <= 3600 * 4).length,
      )
      readonly total30Day = computed(() => 42 + this.requests().filter((r) => r.id.startsWith('req-custom')).length)
    
      readonly filteredRequests = computed(() => {
        const tab = this.selectedTab()
        const q = this.searchQuery().trim().toLowerCase()
        return this.requests().filter((req) => {
          if (tab === 'pending' || tab === 'active') {
            if (req.status !== tab) return false
          } else if (tab === 'expired') {
            if (req.status !== 'expired' && req.status !== 'rejected') return false
          }
          if (q) {
            const hay = [req.requester.name, req.targetRole, req.ticketId, req.justification, req.system]
              .join(' ')
              .toLowerCase()
            if (!hay.includes(q)) return false
          }
          return true
        })
      })
    
      get rootClass(): string {
        return cn('flex flex-col gap-6', this.className)
      }
    
      tabClass(tab: AccessRequestTab): string {
        return cn(
          'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
          this.selectedTab() === tab
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      setSelectedTab(tab: AccessRequestTab): void {
        this.selectedTab.set(tab)
      }
    
      formatSeconds(seconds: number): string {
        return formatSecondsValue(seconds)
      }
    
      roleBadgeVariant(role: string): BadgeVariant {
        if (role.includes('AWS') || role.includes('Admin')) return 'destructive'
        if (role.includes('PII') || role.includes('Decryption')) return 'warning'
        if (role.includes('DB') || role.includes('Read-Only')) return 'info'
        if (role.includes('Stripe')) return 'secondary'
        return 'default'
      }
    
      shortApprover(approver: string): string {
        return approver
          .replace('Security Lead: ', '')
          .replace('Infrastructure Team: ', '')
          .replace('Compliance Officer: ', '')
          .replace('On-Call Lead: ', '')
      }
    
      tick(): void {
        this.requests.update((prev) =>
          prev.map((req) => {
            if (req.status === 'active' && req.secondsLeft > 0) {
              const nextSec = req.secondsLeft - 1
              if (nextSec <= 0) {
                return { ...req, secondsLeft: 0, status: 'expired' as AccessRequestStatus, timeLeftLabel: 'Expired' }
              }
              return { ...req, secondsLeft: nextSec, timeLeftLabel: timeLeftLabelFor(nextSec) }
            }
            return req
          }),
        )
      }
    
      handleCreateRequest(event: SubmitEvent): void {
        event.preventDefault()
        if (!this.formJustification().trim()) return
    
        const duration = this.formDuration()
        let seconds = 3600 * 4
        if (duration.includes('1 Hour')) seconds = 3600
        else if (duration.includes('8 Hours')) seconds = 3600 * 8
        else if (duration.includes('24 Hours')) seconds = 3600 * 24
    
        const role = this.formRole()
        let system = 'AWS Cloud IAM (us-east-1)'
        if (role.includes('DB')) system = 'PostgreSQL Aurora Primary'
        else if (role.includes('Stripe')) system = 'Stripe Merchant Gateway'
        else if (role.includes('PII')) system = 'Customer Data Vault (KMS)'
    
        const newReq: AccessRequest = {
          id: `req-custom-${Date.now()}`,
          requester: {
            name: 'Current Operator (You)',
            email: '[email protected]',
            team: 'Platform Engineering',
            avatar: 'OP',
          },
          targetRole: role,
          system,
          duration,
          timeLeftLabel: 'Pending Approval',
          secondsLeft: seconds,
          justification: this.formJustification(),
          ticketId: `SEC-${Math.floor(100 + Math.random() * 900)}`,
          approver: this.formApprover(),
          status: 'pending',
          createdAt: 'Just now',
          expiresAt: 'Awaiting approval',
        }
    
        this.requests.update((prev) => [newReq, ...prev])
        this.isDialogOpen.set(false)
      }
    
      handleApprove(id: string): void {
        this.requests.update((prev) =>
          prev.map((req) =>
            req.id === id ? { ...req, status: 'active' as const, timeLeftLabel: timeLeftLabelFor(req.secondsLeft) } : req,
          ),
        )
      }
    
      handleReject(id: string): void {
        this.requests.update((prev) =>
          prev.map((req) => (req.id === id ? { ...req, status: 'rejected' as const, timeLeftLabel: 'Rejected' } : req)),
        )
      }
    
      handleRevoke(id: string): void {
        this.requests.update((prev) =>
          prev.map((req) =>
            req.id === id ? { ...req, status: 'expired' as const, secondsLeft: 0, timeLeftLabel: 'Revoked' } : req,
          ),
        )
      }
    
      handleExtend(id: string): void {
        this.requests.update((prev) =>
          prev.map((req) => {
            if (req.id === id && req.status === 'active') {
              const nextSec = req.secondsLeft + 1800
              return { ...req, secondsLeft: nextSec, timeLeftLabel: timeLeftLabelFor(nextSec) }
            }
            return req
          }),
        )
      }
    
      handleReRequest(id: string): void {
        this.requests.update((prev) =>
          prev.map((req) =>
            req.id === id
              ? { ...req, status: 'pending' as const, timeLeftLabel: 'Pending Approval', secondsLeft: 3600 * 4 }
              : req,
          ),
        )
      }
    
      handleCopyToken(id: string): void {
        this.copiedId.set(id)
        clearTimeout(this.copyTimer)
        this.copyTimer = setTimeout(() => {
          if (this.copiedId() === id) this.copiedId.set(null)
        }, 2000)
      }
    }
    
  • src/app/components/blocks/access-request-workflow/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/access-request-workflow.json