Menu

Change language

GitHub repo

Audit Log

blocklegal

Admin activity trail in a SectionCard: live indicator, search across actor/target, action-type filter, and rows pairing an avatar actor with a color-coded action badge, monospace target, IP and relative timestamp. Filtering is functional against the `entries` prop; swap the stub data for your source.

Installation

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

Variants

Loading interactive previews…

Schema

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

AuditEntry
interface AuditEntry {
  id: string
  actor: string
  action: AuditAction
  target: string
  ip: string
  date: Date
}

Files installed (2)

  • src/app/components/blocks/audit-log/audit-log.component.ts8.3 kB
    // Admin activity trail in a SectionCard: live indicator, search across actor/target,
    // action-type filter, and rows pairing an avatar actor with a color-coded action badge,
    // monospace target, IP and relative timestamp. Port of the Vue/React block 1:1 —
    // filtering is functional against the `entries` input; swap the stub data for your source.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import { Download, Fingerprint, LucideAngularModule, RadioTower, Search } 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 { UiInputComponent } from '@/ui/input/input.component'
    import { UiRelativeTimeComponent } from '@/ui/relative-time/relative-time.component'
    import { UiSectionCardComponent } from '@/ui/section-card/section-card.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    
    export type AuditAction = 'sign-in' | 'user.updated' | 'role.changed' | 'export' | 'delete'
    
    export interface AuditEntry {
      id: string
      actor: string
      action: AuditAction
      target: string
      ip: string
      date: Date
    }
    
    const actionTone: Record<AuditAction, BadgeVariant> = {
      'sign-in': 'secondary',
      'user.updated': 'default',
      'role.changed': 'warning',
      export: 'info',
      delete: 'destructive',
    }
    
    const now = Date.now()
    const minutesAgo = (m: number): Date => new Date(now - m * 60_000)
    const hoursAgo = (h: number): Date => new Date(now - h * 3_600_000)
    const daysAgo = (d: number): Date => new Date(now - d * 86_400_000)
    
    const stubEntries: AuditEntry[] = [
      {
        id: 'a1',
        actor: 'Amara Osei',
        action: 'role.changed',
        target: '[email protected] → Editor',
        ip: '77.12.44.9',
        date: minutesAgo(4),
      },
      { id: 'a2', actor: 'System', action: 'export', target: 'payroll-2026-07.csv', ip: '—', date: minutesAgo(38) },
      {
        id: 'a3',
        actor: 'Jonas Weber',
        action: 'sign-in',
        target: 'admin.acme.com',
        ip: '84.190.201.3',
        date: hoursAgo(1),
      },
      {
        id: 'a4',
        actor: 'Priya Nair',
        action: 'user.updated',
        target: 'profile.phone',
        ip: '103.25.8.77',
        date: hoursAgo(3),
      },
      {
        id: 'a5',
        actor: 'Amara Osei',
        action: 'delete',
        target: 'draft-policy-v3.pdf',
        ip: '77.12.44.9',
        date: hoursAgo(5),
      },
      { id: 'a6', actor: 'Marcus Lee', action: 'sign-in', target: 'admin.acme.com', ip: '45.62.110.204', date: daysAgo(1) },
      {
        id: 'a7',
        actor: 'Priya Nair',
        action: 'user.updated',
        target: 'team/onboarding-flow',
        ip: '103.25.8.77',
        date: daysAgo(1),
      },
      { id: 'a8', actor: 'System', action: 'role.changed', target: 'contractors → Viewer', ip: '—', date: daysAgo(2) },
      {
        id: 'a9',
        actor: 'Jonas Weber',
        action: 'export',
        target: 'audit-trail-q2.json',
        ip: '84.190.201.3',
        date: daysAgo(2),
      },
      {
        id: 'a10',
        actor: 'Marcus Lee',
        action: 'delete',
        target: 'staging-deploy-key',
        ip: '45.62.110.204',
        date: daysAgo(3),
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-audit-log, [ui-audit-log]',
      standalone: true,
      // SectionCard renders the card chrome itself: the host stays out of layout.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiInputComponent,
        UiRelativeTimeComponent,
        UiSectionCardComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
      ],
      template: `
        <ui-section-card
          data-slot="audit-log"
          title="Audit log"
          description="Who changed what, when — across your workspace."
          [class]="rootClass"
        >
          <span slot="header-action" class="text-success flex items-center gap-1.5 text-xs font-medium">
            <lucide-icon [img]="RadioTower" class="size-3.5" aria-hidden="true" />
            live
          </span>
    
          <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
            <div class="relative flex-1">
              <lucide-icon
                [img]="Search"
                class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
                aria-hidden="true"
              />
              <ui-input
                [value]="search()"
                (valueChange)="search.set($event)"
                placeholder="Search actor or target…"
                class="pl-8"
              />
            </div>
            <ui-select [value]="actionFilter()" (valueChange)="setActionFilter($event)">
              <button ui-select-trigger class="sm:w-44">
                <ui-select-value placeholder="All actions" />
              </button>
              <ui-select-content>
                <ui-select-item value="all">All actions</ui-select-item>
                @for (action of actions; track action) {
                  <ui-select-item [value]="action">{{ action }}</ui-select-item>
                }
              </ui-select-content>
            </ui-select>
            <button ui-button aria-label="Download attachment" variant="outline" size="sm">
              <lucide-icon [img]="Download" aria-hidden="true" />
              Export
            </button>
          </div>
    
          <ul class="-mb-4 divide-y">
            @if (filtered().length === 0) {
              <li class="text-muted-foreground flex flex-col items-center gap-2 py-10 text-sm">
                <lucide-icon [img]="Fingerprint" class="size-6 opacity-50" aria-hidden="true" />
                No activity matches your filters.
              </li>
            }
            @for (entry of filtered(); track entry.id) {
              <li class="flex items-center gap-3 py-3">
                <ui-avatar class="size-7">
                  <ui-avatar-fallback class="text-xs">{{ initials(entry.actor) }}</ui-avatar-fallback>
                </ui-avatar>
                <div class="min-w-0 flex-1">
                  <div class="flex items-center gap-2">
                    <p class="truncate text-sm font-medium">{{ entry.actor }}</p>
                    <span ui-badge [variant]="tone(entry.action)" class="shrink-0">{{ entry.action }}</span>
                  </div>
                  <p class="text-muted-foreground truncate font-mono text-xs">{{ entry.target }}</p>
                </div>
                <div class="hidden text-right sm:block">
                  <ui-relative-time [date]="entry.date" class="text-muted-foreground block text-xs" />
                  <p class="text-muted-foreground/70 font-mono text-xs">{{ entry.ip }}</p>
                </div>
              </li>
            }
          </ul>
        </ui-section-card>
      `,
    })
    export class UiAuditLogComponent {
      protected readonly Download = Download
      protected readonly Fingerprint = Fingerprint
      protected readonly RadioTower = RadioTower
      protected readonly Search = Search
    
      protected readonly actions: AuditAction[] = ['sign-in', 'user.updated', 'role.changed', 'export', 'delete']
    
      @Input() set entries(v: AuditEntry[] | undefined) {
        this.entriesInput.set(v)
      }
      @Input() set initialSearch(v: string) {
        this.search.set(v ?? '')
      }
      @Input() set initialAction(v: 'all' | AuditAction) {
        this.actionFilter.set(v ?? 'all')
      }
      @Input('class') className?: string
    
      readonly entriesInput = signal<AuditEntry[] | undefined>(undefined)
      readonly search = signal('')
      readonly actionFilter = signal<'all' | AuditAction>('all')
    
      readonly source = computed(() => this.entriesInput() ?? stubEntries)
    
      readonly filtered = computed(() => {
        const query = this.search()
        const action = this.actionFilter()
        return this.source().filter((entry) => {
          const matchesQuery = query === '' || `${entry.actor} ${entry.target}`.toLowerCase().includes(query.toLowerCase())
          const matchesAction = action === 'all' || entry.action === action
          return matchesQuery && matchesAction
        })
      })
    
      get rootClass(): string {
        return cn(this.className)
      }
    
      tone(action: AuditAction): BadgeVariant {
        return actionTone[action]
      }
    
      setActionFilter(value: string): void {
        this.actionFilter.set(value as 'all' | AuditAction)
      }
    
      initials(name: string): string {
        return name
          .split(' ')
          .map((part) => part[0])
          .slice(0, 2)
          .join('')
          .toUpperCase()
      }
    }
    
  • src/app/components/blocks/audit-log/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/audit-log.json