Menu

Change language

GitHub repo

Password Generator Widget

blocksecurity

1Password and Bitwarden-style customizable password and passphrase generator with entropy strength meter, character type toggles, memorable Diceware passphrases, PIN codes, and volatile history drawer.

Installation

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

Variants

Loading interactive previews…

Schema

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

HistoryItem
interface HistoryItem {
  id: string
  secret: string
  mode: GeneratorMode
  entropy: number
  strengthLabel: string
  createdAt: string
}
FormattedChar
interface FormattedChar {
  char: string
  type: 'number' | 'symbol' | 'letter' | 'separator'
}

Files installed (2)

  • src/app/components/blocks/password-generator-widget/password-generator-widget.component.ts40.4 kB
    // 1Password / Bitwarden style customizable password, Diceware passphrase and PIN
    // generator: cryptographic output with syntax highlighting, entropy strength meter with
    // crack-time estimate, per-mode configuration tabs (length slider + presets, character
    // pools, separator grid, PIN rules), and a volatile masked history drawer. Port of the
    // React block 1:1. Option changes regenerate quietly (no history spam); Regenerate logs.
    import { ChangeDetectionStrategy, Component, Input, OnInit, computed, signal } from '@angular/core'
    import {
      Check,
      Clock,
      Copy,
      Dice5,
      Eye,
      EyeOff,
      Hash,
      History,
      KeyRound,
      Lock,
      LucideAngularModule,
      RefreshCw,
      Shield,
      ShieldCheck,
      Sliders,
      Trash2,
    } 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,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSliderComponent } from '@/ui/slider/slider.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    import {
      UiTabsComponent,
      UiTabsContentComponent,
      UiTabsListComponent,
      UiTabsTriggerComponent,
    } from '@/ui/tabs/tabs.component'
    
    export type GeneratorMode = 'password' | 'passphrase' | 'pin'
    
    export interface HistoryItem {
      id: string
      secret: string
      mode: GeneratorMode
      entropy: number
      strengthLabel: string
      createdAt: string
    }
    
    const WORD_LIST = [
      'anchor',
      'apple',
      'apron',
      'arctic',
      'arrow',
      'atlas',
      'autumn',
      'bacon',
      'badge',
      'banner',
      'beacon',
      'blanket',
      'breeze',
      'bridge',
      'cabin',
      'cactus',
      'camera',
      'candle',
      'canyon',
      'carpet',
      'castle',
      'cedar',
      'celestial',
      'channel',
      'cherry',
      'chimney',
      'cipher',
      'circle',
      'clay',
      'cliff',
      'clock',
      'cloud',
      'clover',
      'cobalt',
      'coffee',
      'comet',
      'compass',
      'copper',
      'coral',
      'cosmos',
      'crater',
      'creek',
      'crystal',
      'curtain',
      'delta',
      'desert',
      'diamond',
      'dolphin',
      'dragon',
      'drift',
      'eagle',
      'earth',
      'echo',
      'eclipse',
      'ember',
      'emerald',
      'engine',
      'falcon',
      'feather',
      'fender',
      'filter',
      'flame',
      'flask',
      'forest',
      'fossil',
      'galaxy',
      'garden',
      'garlic',
      'garnet',
      'gem',
      'glacier',
      'glass',
      'globe',
      'granite',
      'gravel',
      'grove',
      'harbor',
      'haven',
      'hawk',
      'hazel',
      'horizon',
      'island',
      'ivory',
      'jungle',
      'jupiter',
      'kettle',
      'lagoon',
      'lantern',
      'laser',
      'lava',
      'leaf',
      'lemon',
      'leopard',
      'lighthouse',
      'lightning',
      'lotus',
      'lunar',
      'magnet',
      'mango',
      'maple',
      'marble',
      'matrix',
      'meadow',
      'meteor',
      'mineral',
      'mirror',
      'monarch',
      'moon',
      'mosaic',
      'mountain',
      'nebula',
      'nickel',
      'oasis',
      'ocean',
      'olive',
      'onyx',
      'opal',
      'orbit',
      'orchid',
      'oxygen',
      'palace',
      'panther',
      'paradise',
      'pebble',
      'pelican',
      'peacock',
      'phoenix',
      'pillar',
      'planet',
      'plasma',
      'platinum',
      'polar',
      'prism',
      'pulse',
      'pyramid',
      'quartz',
      'quiver',
      'radar',
      'rainbow',
      'raven',
      'reef',
      'ridge',
      'ripple',
      'river',
      'rocket',
      'ruby',
      'safari',
      'sail',
      'sapphire',
      'saturn',
      'shadow',
      'shield',
      'sierra',
      'silver',
      'solstice',
      'spark',
      'sphere',
      'spiral',
      'spring',
      'spruce',
      'star',
      'stone',
      'summit',
      'sunset',
      'surge',
      'timber',
      'topaz',
      'torch',
      'trail',
      'treasure',
      'tundra',
      'valley',
      'vector',
      'velvet',
      'vessel',
      'vintage',
      'violet',
      'vortex',
      'voyage',
      'vulcan',
      'walnut',
      'willow',
      'wind',
      'winter',
      'zenith',
      'zephyr',
    ]
    
    const UPPERCASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    const LOWERCASE_CHARS = 'abcdefghijklmnopqrstuvwxyz'
    const NUMBER_CHARS = '0123456789'
    const SYMBOL_CHARS = '!@#$%^&*()_+-=[]{}|;:,.<>?'
    const AMBIGUOUS_CHARS = new Set(['l', '1', 'I', 'O', '0', 'o', '|', '`', "'", '"', ',', ';'])
    
    const PASSWORD_PRESETS = [12, 16, 20, 24, 32, 48, 64]
    const WORD_PRESETS = [3, 4, 5, 6, 8]
    const PIN_PRESETS = [4, 6, 8, 10, 12, 16]
    
    const SEPARATORS = [
      { id: '-', label: 'Hyphen (-)' },
      { id: '_', label: 'Under (_)' },
      { id: '.', label: 'Period (.)' },
      { id: ' ', label: 'Space ( )' },
      { id: '/', label: 'Slash (/)' },
    ]
    
    export interface FormattedChar {
      char: string
      type: 'number' | 'symbol' | 'letter' | 'separator'
    }
    
    function classifyChar(char: string): FormattedChar['type'] {
      if (/[0-9]/.test(char)) return 'number'
      if (/[-_. /]/.test(char)) return 'separator'
      if (/[^a-zA-Z0-9]/.test(char)) return 'symbol'
      return 'letter'
    }
    
    function getRandomInt(max: number): number {
      if (typeof window !== 'undefined' && window.crypto && window.crypto.getRandomValues) {
        const array = new Uint32Array(1)
        window.crypto.getRandomValues(array)
        return array[0]! % max
      }
      return Math.floor(Math.random() * max)
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-password-generator-widget, [ui-password-generator-widget]',
      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,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiSeparatorComponent,
        UiSliderComponent,
        UiSwitchComponent,
        UiTabsComponent,
        UiTabsContentComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
      ],
      template: `
        <div data-slot="password-generator-widget" [class]="rootClass">
          <!-- HERO: GENERATOR OUTPUT CARD -->
          <div ui-card class="overflow-hidden shadow-xs">
            <div ui-card-header class="border-border/50 bg-muted/20 border-b pb-3">
              <div class="flex flex-wrap items-center justify-between gap-2">
                <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]="KeyRound" class="size-4" />
                  </div>
                  <div>
                    <h3 ui-card-title class="text-base font-semibold">Generated Secret</h3>
                    <p ui-card-description class="text-xs">Client-side cryptographic output &amp; entropy estimation</p>
                  </div>
                </div>
                <div class="flex items-center gap-2">
                  <span ui-badge variant="outline" class="gap-1.5 text-xs font-normal">
                    <span class="bg-success size-2 animate-pulse rounded-full"></span>
                    100% Client-Side
                  </span>
                </div>
              </div>
            </div>
    
            <div ui-card-content class="space-y-6 pt-6">
              <div
                class="border-border/80 bg-muted/40 dark:bg-muted/20 relative flex min-h-[4.75rem] w-full flex-col justify-center gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
              >
                <div
                  class="flex flex-wrap items-center gap-x-0.5 gap-y-1 pr-2 font-mono text-lg font-medium tracking-wider break-all select-all md:text-xl"
                >
                  @if (formattedCharacters().length > 0) {
                    @for (item of formattedCharacters(); track $index) {
                      <span [class]="charClass(item.type)">{{ item.char }}</span>
                    }
                  } @else {
                    <span class="text-muted-foreground text-sm">Generating secret...</span>
                  }
                </div>
    
                <div class="flex shrink-0 items-center gap-2 self-end sm:self-center">
                  <button
                    ui-button
                    variant="outline"
                    size="sm"
                    class="h-9 gap-1.5 px-3 active:scale-95"
                    (click)="handleManualRegenerate()"
                  >
                    <lucide-icon [img]="RefreshCw" [class]="spinClass" />
                    <span class="text-xs font-medium">Regenerate</span>
                  </button>
    
                  <button ui-button variant="default" size="sm" [class]="copyClass" (click)="copySecret(currentSecret())">
                    @if (copied()) {
                      <lucide-icon [img]="Check" class="size-4 stroke-[2.5]" />
                    } @else {
                      <lucide-icon [img]="Copy" class="size-4" />
                    }
                    <span class="text-xs font-medium">{{ copied() ? 'Copied!' : 'Copy' }}</span>
                  </button>
                </div>
              </div>
    
              <!-- Entropy & Strength Meter -->
              <div class="border-border/60 bg-muted/10 space-y-2.5 rounded-lg border p-4">
                <div class="flex flex-wrap items-center justify-between gap-2 text-xs">
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="ShieldCheck" class="size-4" [class]="currentStrength().color" />
                    <span class="text-foreground font-semibold">{{ currentStrength().label }}</span>
                    <span class="text-muted-foreground font-mono">{{ currentStrength().entropy }}-bit entropy</span>
                  </div>
                  <div class="text-muted-foreground flex items-center gap-1.5">
                    <lucide-icon [img]="Clock" class="size-3.5" />
                    <span>Crack time:</span>
                    <span class="text-foreground font-medium">{{ currentStrength().crackTime }}</span>
                  </div>
                </div>
    
                <div class="grid h-2 w-full grid-cols-4 gap-1.5 overflow-hidden rounded-full">
                  <div class="rounded-full transition-colors duration-300" [class]="segClass(1)"></div>
                  <div class="rounded-full transition-colors duration-300" [class]="segClass(2)"></div>
                  <div class="rounded-full transition-colors duration-300" [class]="segClass(3)"></div>
                  <div class="rounded-full transition-colors duration-300" [class]="segClass(4)"></div>
                </div>
    
                <div class="text-muted-foreground flex flex-wrap items-center gap-4 pt-1 text-xs">
                  <span class="flex items-center gap-1.5">
                    <span class="bg-foreground size-2 rounded-full"></span>
                    Letters
                  </span>
                  <span class="flex items-center gap-1.5">
                    <span class="bg-info size-2 rounded-full"></span>
                    Numbers (0-9)
                  </span>
                  <span class="flex items-center gap-1.5">
                    <span class="bg-warning size-2 rounded-full"></span>
                    Symbols (!@#$)
                  </span>
                </div>
              </div>
            </div>
          </div>
    
          <!-- GENERATOR CONFIGURATION CARD -->
          <div ui-card class="shadow-xs">
            <div ui-card-header class="border-border/50 border-b pb-3">
              <div class="flex items-center gap-2">
                <div class="bg-muted text-muted-foreground flex size-8 items-center justify-center rounded-lg">
                  <lucide-icon [img]="Sliders" class="size-4" />
                </div>
                <div>
                  <h3 ui-card-title class="text-base font-semibold">Generator Configuration</h3>
                  <p ui-card-description class="text-xs">
                    Customize character pools, length constraints, and format patterns
                  </p>
                </div>
              </div>
            </div>
    
            <div ui-card-content class="pt-6">
              <ui-tabs [value]="mode()" (valueChange)="setMode($event)" class="w-full">
                <ui-tabs-list class="mb-6 grid w-full grid-cols-3">
                  <button ui-tabs-trigger value="password" class="gap-1.5 text-xs">
                    <lucide-icon [img]="Lock" class="size-3.5" />
                    <span>Random Password</span>
                  </button>
                  <button ui-tabs-trigger value="passphrase" class="gap-1.5 text-xs">
                    <lucide-icon [img]="Dice5" class="size-3.5" />
                    <span>Passphrase</span>
                  </button>
                  <button ui-tabs-trigger value="pin" class="gap-1.5 text-xs">
                    <lucide-icon [img]="Hash" class="size-3.5" />
                    <span>PIN Code</span>
                  </button>
                </ui-tabs-list>
    
                <!-- TAB 1: RANDOM PASSWORD -->
                <ui-tabs-content value="password" class="mt-0 space-y-6">
                  <div class="space-y-3">
                    <div class="flex items-center justify-between">
                      <label class="text-foreground text-sm font-medium">Password Length</label>
                      <span ui-badge variant="secondary" class="font-mono text-xs font-semibold tabular-nums">
                        {{ passwordLength() }} characters
                      </span>
                    </div>
    
                    <ui-slider
                      [value]="[passwordLength()]"
                      [min]="8"
                      [max]="64"
                      [step]="1"
                      class="w-full py-1"
                      (valueChange)="setPasswordLength($event[0] ?? passwordLength())"
                    />
    
                    <div class="flex flex-wrap items-center gap-1.5 pt-1">
                      <span class="text-muted-foreground mr-1 text-xs">Presets:</span>
                      @for (preset of passwordPresets; track preset) {
                        <button
                          type="button"
                          [class]="presetClass(passwordLength() === preset)"
                          (click)="setPasswordLength(preset)"
                        >
                          {{ preset }}
                        </button>
                      }
                    </div>
                  </div>
    
                  <ui-separator />
    
                  <div class="space-y-3">
                    <label class="text-muted-foreground text-xs font-medium"> Character Pools </label>
    
                    <div class="border-border/80 bg-muted/20 space-y-3 rounded-lg border p-4">
                      <div class="flex items-center justify-between">
                        <div class="space-y-0.5">
                          <div class="text-foreground flex items-center gap-2 text-sm font-medium">
                            <span>Uppercase Letters</span>
                            <span class="text-muted-foreground font-mono text-xs font-normal">(A-Z)</span>
                          </div>
                          <p class="text-muted-foreground text-xs">Include capital Latin characters</p>
                        </div>
                        <ui-switch [checked]="includeUpper()" (checkedChange)="toggleUpper($event)" />
                      </div>
    
                      <ui-separator />
    
                      <div class="flex items-center justify-between">
                        <div class="space-y-0.5">
                          <div class="text-foreground flex items-center gap-2 text-sm font-medium">
                            <span>Lowercase Letters</span>
                            <span class="text-muted-foreground font-mono text-xs font-normal">(a-z)</span>
                          </div>
                          <p class="text-muted-foreground text-xs">Include small Latin characters</p>
                        </div>
                        <ui-switch [checked]="includeLower()" (checkedChange)="toggleLower($event)" />
                      </div>
    
                      <ui-separator />
    
                      <div class="flex items-center justify-between">
                        <div class="space-y-0.5">
                          <div class="text-foreground flex items-center gap-2 text-sm font-medium">
                            <span>Numbers</span>
                            <span class="text-info font-mono text-xs font-semibold">(0-9)</span>
                          </div>
                          <p class="text-muted-foreground text-xs">Include numeric digits 0 through 9</p>
                        </div>
                        <ui-switch [checked]="includeNumbers()" (checkedChange)="toggleNumbers($event)" />
                      </div>
    
                      <ui-separator />
    
                      <div class="flex items-center justify-between">
                        <div class="space-y-0.5">
                          <div class="text-foreground flex items-center gap-2 text-sm font-medium">
                            <span>Special Symbols</span>
                            <span class="text-warning font-mono text-xs font-semibold">(!@#$%^&*)</span>
                          </div>
                          <p class="text-muted-foreground text-xs">Include non-alphanumeric punctuation marks</p>
                        </div>
                        <ui-switch [checked]="includeSymbols()" (checkedChange)="toggleSymbols($event)" />
                      </div>
    
                      <ui-separator />
    
                      <div class="flex items-center justify-between">
                        <div class="space-y-0.5">
                          <div class="text-foreground flex items-center gap-2 text-sm font-medium">
                            <span>Avoid Ambiguous Characters</span>
                            <span class="text-muted-foreground font-mono text-xs font-normal">(l, 1, I, O, 0)</span>
                          </div>
                          <p class="text-muted-foreground text-xs">Exclude confusing lookalike letters and digits</p>
                        </div>
                        <ui-switch [checked]="avoidAmbiguous()" (checkedChange)="setAvoidAmbiguous($event)" />
                      </div>
                    </div>
                  </div>
                </ui-tabs-content>
    
                <!-- TAB 2: MEMORABLE PASSPHRASE -->
                <ui-tabs-content value="passphrase" class="mt-0 space-y-6">
                  <div class="space-y-3">
                    <div class="flex items-center justify-between">
                      <label class="text-foreground text-sm font-medium">Number of Words</label>
                      <span ui-badge variant="secondary" class="font-mono text-xs font-semibold tabular-nums">
                        {{ wordCount() }} words
                      </span>
                    </div>
    
                    <ui-slider
                      [value]="[wordCount()]"
                      [min]="3"
                      [max]="8"
                      [step]="1"
                      class="w-full py-1"
                      (valueChange)="setWordCount($event[0] ?? wordCount())"
                    />
    
                    <div class="flex flex-wrap items-center gap-1.5 pt-1">
                      <span class="text-muted-foreground mr-1 text-xs">Presets:</span>
                      @for (preset of wordPresets; track preset) {
                        <button type="button" [class]="presetClass(wordCount() === preset)" (click)="setWordCount(preset)">
                          {{ preset }} words
                        </button>
                      }
                    </div>
                  </div>
    
                  <ui-separator />
    
                  <div class="space-y-3">
                    <label class="text-muted-foreground text-xs font-medium"> Word Separator </label>
    
                    <div class="grid grid-cols-5 gap-2">
                      @for (sep of separators; track sep.id) {
                        <button
                          type="button"
                          [class]="separatorClass(separator() === sep.id)"
                          (click)="setSeparator(sep.id)"
                        >
                          <span class="font-mono text-sm font-semibold">{{ sep.id === ' ' ? '␣' : sep.id }}</span>
                          <span class="mt-0.5 truncate text-xs">{{ sepLabel(sep.label) }}</span>
                        </button>
                      }
                    </div>
                  </div>
    
                  <ui-separator />
    
                  <div class="border-border/80 bg-muted/20 space-y-3 rounded-lg border p-4">
                    <div class="flex items-center justify-between">
                      <div class="space-y-0.5">
                        <div class="text-foreground text-sm font-medium">Capitalize Words</div>
                        <p class="text-muted-foreground text-xs">Transform each word into TitleCase for easy reading</p>
                      </div>
                      <ui-switch [checked]="capitalizeWords()" (checkedChange)="setCapitalizeWords($event)" />
                    </div>
    
                    <ui-separator />
    
                    <div class="flex items-center justify-between">
                      <div class="space-y-0.5">
                        <div class="text-foreground text-sm font-medium">Append Random Number</div>
                        <p class="text-muted-foreground text-xs">Add a 2-digit number (10-99) for additional security</p>
                      </div>
                      <ui-switch
                        [checked]="includeNumberInPassphrase()"
                        (checkedChange)="setIncludeNumberInPassphrase($event)"
                      />
                    </div>
                  </div>
                </ui-tabs-content>
    
                <!-- TAB 3: PIN CODE -->
                <ui-tabs-content value="pin" class="mt-0 space-y-6">
                  <div class="space-y-3">
                    <div class="flex items-center justify-between">
                      <label class="text-foreground text-sm font-medium">PIN Length</label>
                      <span ui-badge variant="secondary" class="font-mono text-xs font-semibold tabular-nums">
                        {{ pinLength() }} digits
                      </span>
                    </div>
    
                    <ui-slider
                      [value]="[pinLength()]"
                      [min]="4"
                      [max]="16"
                      [step]="1"
                      class="w-full py-1"
                      (valueChange)="setPinLength($event[0] ?? pinLength())"
                    />
    
                    <div class="flex flex-wrap items-center gap-1.5 pt-1">
                      <span class="text-muted-foreground mr-1 text-xs">Presets:</span>
                      @for (preset of pinPresets; track preset) {
                        <button type="button" [class]="presetClass(pinLength() === preset)" (click)="setPinLength(preset)">
                          {{ preset }} digits
                        </button>
                      }
                    </div>
                  </div>
    
                  <ui-separator />
    
                  <div class="border-border/80 bg-muted/20 space-y-3 rounded-lg border p-4">
                    <div class="flex items-center justify-between">
                      <div class="space-y-0.5">
                        <div class="text-foreground text-sm font-medium">Avoid Consecutive Repeated Digits</div>
                        <p class="text-muted-foreground text-xs">
                          Prevent obvious repetitive patterns like &ldquo;1122&rdquo; or &ldquo;8888&rdquo;
                        </p>
                      </div>
                      <ui-switch [checked]="avoidPinRepeats()" (checkedChange)="setAvoidPinRepeats($event)" />
                    </div>
                  </div>
                </ui-tabs-content>
              </ui-tabs>
            </div>
          </div>
    
          <!-- RECENT HISTORY CARD -->
          <div ui-card class="shadow-xs">
            <div ui-card-header class="border-border/50 border-b pb-3">
              <div class="flex flex-wrap items-center justify-between gap-2">
                <div class="flex items-center gap-2">
                  <div class="bg-muted text-muted-foreground flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="History" class="size-4" />
                  </div>
                  <div>
                    <h3 ui-card-title class="text-base font-semibold">Generated History</h3>
                    <p ui-card-description class="text-xs">
                      Stored in volatile browser memory only • Cleared on page refresh
                    </p>
                  </div>
                </div>
                @if (history().length > 0) {
                  <button
                    ui-button
                    variant="ghost"
                    size="sm"
                    class="text-muted-foreground hover:text-destructive h-8 gap-1.5 text-xs"
                    (click)="clearHistory()"
                  >
                    <lucide-icon [img]="Trash2" class="size-3.5" />
                    <span>Clear History</span>
                  </button>
                }
              </div>
            </div>
    
            <div ui-card-content class="pt-4">
              @if (history().length === 0) {
                <div class="text-muted-foreground py-8 text-center text-xs">
                  No recent secrets in session history. Regenerate or copy above to log items here.
                </div>
              } @else {
                <div class="divide-border/60 divide-y">
                  @for (item of history(); track item.id) {
                    <div
                      class="group hover:bg-muted/30 flex flex-col justify-between gap-3 rounded-md px-1 py-3 transition-colors sm:flex-row sm:items-center"
                    >
                      <div class="flex min-w-0 items-center gap-3">
                        <div
                          class="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-md"
                        >
                          @if (item.mode === 'password') {
                            <lucide-icon [img]="Lock" class="size-3.5" />
                          } @else if (item.mode === 'passphrase') {
                            <lucide-icon [img]="Dice5" class="size-3.5" />
                          } @else {
                            <lucide-icon [img]="Hash" class="size-3.5" />
                          }
                        </div>
    
                        <div class="min-w-0 space-y-1">
                          <div class="flex items-center gap-2">
                            <div class="max-w-[18rem] truncate font-mono text-xs font-medium md:max-w-md">
                              @if (isRevealed(item.id)) {
                                @for (sub of formatItem(item.secret); track $index) {
                                  <span [class]="charClass(sub.type)">{{ sub.char }}</span>
                                }
                              } @else {
                                <span class="text-muted-foreground tracking-widest">••••••••••••••••</span>
                              }
                            </div>
    
                            <button
                              type="button"
                              class="text-muted-foreground hover:text-foreground cursor-pointer p-0.5 transition-colors"
                              title="Toggle Visibility"
                              (click)="toggleHistoryReveal(item.id)"
                            >
                              @if (isRevealed(item.id)) {
                                <lucide-icon [img]="EyeOff" class="size-3.5" />
                              } @else {
                                <lucide-icon [img]="Eye" class="size-3.5" />
                              }
                            </button>
                          </div>
    
                          <div class="text-muted-foreground flex items-center gap-2 text-xs">
                            <span ui-badge variant="outline" class="px-1.5 py-0 text-xs font-normal">
                              {{ item.mode }}
                            </span>
                            <span>•</span>
                            <span>{{ item.entropy }}-bit ({{ item.strengthLabel }})</span>
                            <span>•</span>
                            <span>{{ item.createdAt }}</span>
                          </div>
                        </div>
                      </div>
    
                      <div class="flex shrink-0 items-center gap-2 self-end sm:self-center">
                        <button
                          ui-button
                          variant="outline"
                          size="sm"
                          [class]="historyCopyClass(item.id)"
                          (click)="copySecret(item.secret, true, item.id)"
                        >
                          @if (copiedHistoryId() === item.id) {
                            <lucide-icon [img]="Check" class="size-3.5" />
                          } @else {
                            <lucide-icon [img]="Copy" class="size-3.5" />
                          }
                          <span>{{ copiedHistoryId() === item.id ? 'Copied' : 'Copy' }}</span>
                        </button>
                      </div>
                    </div>
                  }
                </div>
              }
            </div>
            <div
              ui-card-footer
              class="text-muted-foreground border-border/40 flex items-center justify-between border-t pt-3 pb-4 text-xs"
            >
              <span class="flex items-center gap-1.5">
                <lucide-icon [img]="Shield" class="text-success size-3.5" />
                Cryptographically random • Never transmitted over network
              </span>
              <span class="font-mono tabular-nums">{{ history().length }} entries</span>
            </div>
          </div>
        </div>
      `,
    })
    export class UiPasswordGeneratorWidgetComponent implements OnInit {
      protected readonly Check = Check
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly Dice5 = Dice5
      protected readonly Eye = Eye
      protected readonly EyeOff = EyeOff
      protected readonly Hash = Hash
      protected readonly History = History
      protected readonly KeyRound = KeyRound
      protected readonly Lock = Lock
      protected readonly RefreshCw = RefreshCw
      protected readonly Shield = Shield
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Sliders = Sliders
      protected readonly Trash2 = Trash2
    
      protected readonly passwordPresets = PASSWORD_PRESETS
      protected readonly wordPresets = WORD_PRESETS
      protected readonly pinPresets = PIN_PRESETS
      protected readonly separators = SEPARATORS
    
      @Input('class') className?: string
    
      readonly mode = signal<GeneratorMode>('password')
      readonly passwordLength = signal(20)
      readonly includeUpper = signal(true)
      readonly includeLower = signal(true)
      readonly includeNumbers = signal(true)
      readonly includeSymbols = signal(true)
      readonly avoidAmbiguous = signal(true)
    
      readonly wordCount = signal(5)
      readonly separator = signal('-')
      readonly capitalizeWords = signal(true)
      readonly includeNumberInPassphrase = signal(true)
    
      readonly pinLength = signal(6)
      readonly avoidPinRepeats = signal(true)
    
      readonly currentSecret = signal('')
      readonly copied = signal(false)
      readonly isSpinning = signal(false)
      readonly copiedHistoryId = signal<string | null>(null)
      readonly revealed = signal<Record<string, boolean>>({})
    
      readonly history = signal<HistoryItem[]>([
        {
          id: 'hist-1',
          secret: 'kX9#mQ4$vL2!pW8*jT7^',
          mode: 'password',
          entropy: 114,
          strengthLabel: 'Very Strong',
          createdAt: 'Just now',
        },
        {
          id: 'hist-2',
          secret: 'Celestial-Falcon-Lagoon-Timber-83',
          mode: 'passphrase',
          entropy: 48,
          strengthLabel: 'Fair',
          createdAt: '4m ago',
        },
        {
          id: 'hist-3',
          secret: '928401',
          mode: 'pin',
          entropy: 20,
          strengthLabel: 'Very Weak',
          createdAt: '12m ago',
        },
      ])
    
      ngOnInit(): void {
        this.regenerateSecret(false)
      }
    
      readonly currentStrength = computed(() => {
        let entropy = 0
        if (this.mode() === 'password') {
          let poolSize = 0
          if (this.includeUpper()) poolSize += this.avoidAmbiguous() ? 24 : 26
          if (this.includeLower()) poolSize += this.avoidAmbiguous() ? 24 : 26
          if (this.includeNumbers()) poolSize += this.avoidAmbiguous() ? 8 : 10
          if (this.includeSymbols()) poolSize += this.avoidAmbiguous() ? 26 : 28
          entropy = poolSize > 1 ? Math.round(this.passwordLength() * Math.log2(poolSize)) : 0
        } else if (this.mode() === 'passphrase') {
          entropy = this.wordCount() * Math.log2(WORD_LIST.length)
          if (this.capitalizeWords()) entropy += this.wordCount() * 1
          if (this.includeNumberInPassphrase()) entropy += Math.log2(90)
          entropy = Math.round(entropy)
        } else {
          entropy = Math.round(this.pinLength() * Math.log2(10))
        }
    
        if (entropy < 30) {
          return {
            entropy,
            score: 1,
            label: 'Very Weak',
            crackTime: 'Instant (< 1 ms)',
            color: 'text-destructive',
            barClass: 'bg-destructive',
          }
        }
        if (entropy < 50) {
          return {
            entropy,
            score: 2,
            label: 'Weak',
            crackTime: 'A few minutes',
            color: 'text-orange-500',
            barClass: 'bg-orange-500',
          }
        }
        if (entropy < 70) {
          return {
            entropy,
            score: 3,
            label: 'Fair',
            crackTime: 'Several months',
            color: 'text-warning',
            barClass: 'bg-warning',
          }
        }
        if (entropy < 95) {
          return {
            entropy,
            score: 4,
            label: 'Strong',
            crackTime: 'Hundreds of years',
            color: 'text-success',
            barClass: 'bg-success',
          }
        }
        return {
          entropy,
          score: 4,
          label: 'Very Strong',
          crackTime: '100+ billion years',
          color: 'text-success',
          barClass: 'bg-success',
        }
      })
    
      readonly formattedCharacters = computed<FormattedChar[]>(() => {
        const secret = this.currentSecret()
        if (!secret) return []
        return secret.split('').map((char) => ({ char, type: classifyChar(char) }))
      })
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-4xl space-y-6', this.className)
      }
    
      get spinClass(): string {
        return cn('size-4', this.isSpinning() && 'text-primary animate-spin')
      }
    
      get copyClass(): string {
        return cn('h-9 gap-1.5 px-3.5 active:scale-95', this.copied() && 'bg-success hover:bg-success text-white')
      }
    
      charClass(type: FormattedChar['type']): string {
        if (type === 'number') return 'text-info font-semibold'
        if (type === 'symbol') return 'text-warning font-semibold'
        if (type === 'separator') return 'text-muted-foreground px-0.5 font-normal'
        return 'text-foreground font-medium'
      }
    
      segClass(index: number): string {
        return this.currentStrength().score >= index ? this.currentStrength().barClass : 'bg-muted/80 dark:bg-muted'
      }
    
      presetClass(active: boolean): string {
        return cn(
          'cursor-pointer rounded-md border px-2.5 py-1 text-xs font-medium transition-colors',
          active
            ? 'bg-primary text-primary-foreground border-primary shadow-xs'
            : 'bg-muted/40 text-muted-foreground border-border hover:bg-muted hover:text-foreground',
        )
      }
    
      separatorClass(active: boolean): string {
        return cn(
          'flex cursor-pointer flex-col items-center justify-center rounded-lg border p-2.5 text-xs font-medium transition-colors',
          active
            ? 'bg-primary/10 border-primary text-primary font-semibold shadow-xs'
            : 'border-border bg-background hover:bg-muted/50 text-muted-foreground hover:text-foreground',
        )
      }
    
      sepLabel(label: string): string {
        return label.split(' ')[0]!
      }
    
      historyCopyClass(id: string): string {
        return cn(
          'h-8 gap-1.5 px-2.5 text-xs active:scale-95',
          this.copiedHistoryId() === id && 'bg-success hover:bg-success text-white',
        )
      }
    
      formatItem(text: string): FormattedChar[] {
        return text.split('').map((char) => ({ char, type: classifyChar(char) }))
      }
    
      isRevealed(id: string): boolean {
        return !!this.revealed()[id]
      }
    
      setMode(value: string): void {
        this.mode.set(value as GeneratorMode)
        this.regenerateSecret(false)
      }
    
      setPasswordLength(value: number): void {
        this.passwordLength.set(value)
        this.regenerateSecret(false)
      }
    
      setWordCount(value: number): void {
        this.wordCount.set(value)
        this.regenerateSecret(false)
      }
    
      setPinLength(value: number): void {
        this.pinLength.set(value)
        this.regenerateSecret(false)
      }
    
      setSeparator(value: string): void {
        this.separator.set(value)
        this.regenerateSecret(false)
      }
    
      setCapitalizeWords(value: boolean): void {
        this.capitalizeWords.set(value)
        this.regenerateSecret(false)
      }
    
      setIncludeNumberInPassphrase(value: boolean): void {
        this.includeNumberInPassphrase.set(value)
        this.regenerateSecret(false)
      }
    
      setAvoidAmbiguous(value: boolean): void {
        this.avoidAmbiguous.set(value)
        this.regenerateSecret(false)
      }
    
      setAvoidPinRepeats(value: boolean): void {
        this.avoidPinRepeats.set(value)
        this.regenerateSecret(false)
      }
    
      // At least one character pool stays active in password mode.
      toggleUpper(val: boolean): void {
        if (!val && !this.includeLower() && !this.includeNumbers() && !this.includeSymbols()) return
        this.includeUpper.set(val)
        this.regenerateSecret(false)
      }
    
      toggleLower(val: boolean): void {
        if (!val && !this.includeUpper() && !this.includeNumbers() && !this.includeSymbols()) return
        this.includeLower.set(val)
        this.regenerateSecret(false)
      }
    
      toggleNumbers(val: boolean): void {
        if (!val && !this.includeUpper() && !this.includeLower() && !this.includeSymbols()) return
        this.includeNumbers.set(val)
        this.regenerateSecret(false)
      }
    
      toggleSymbols(val: boolean): void {
        if (!val && !this.includeUpper() && !this.includeLower() && !this.includeNumbers()) return
        this.includeSymbols.set(val)
        this.regenerateSecret(false)
      }
    
      generatePassword(): string {
        let pool = ''
        const requiredChars: string[] = []
    
        const filterPool = (chars: string): string => {
          if (!this.avoidAmbiguous()) return chars
          return chars
            .split('')
            .filter((c) => !AMBIGUOUS_CHARS.has(c))
            .join('')
        }
    
        const uppers = filterPool(UPPERCASE_CHARS)
        const lowers = filterPool(LOWERCASE_CHARS)
        const numbers = filterPool(NUMBER_CHARS)
        const symbols = filterPool(SYMBOL_CHARS)
    
        if (this.includeUpper() && uppers.length > 0) {
          pool += uppers
          requiredChars.push(uppers[getRandomInt(uppers.length)]!)
        }
        if (this.includeLower() && lowers.length > 0) {
          pool += lowers
          requiredChars.push(lowers[getRandomInt(lowers.length)]!)
        }
        if (this.includeNumbers() && numbers.length > 0) {
          pool += numbers
          requiredChars.push(numbers[getRandomInt(numbers.length)]!)
        }
        if (this.includeSymbols() && symbols.length > 0) {
          pool += symbols
          requiredChars.push(symbols[getRandomInt(symbols.length)]!)
        }
    
        if (pool.length === 0) {
          pool = lowers.length > 0 ? lowers : 'abcdefghjkmnpqrstuvwxyz'
          requiredChars.push(pool[getRandomInt(pool.length)]!)
        }
    
        const result: string[] = [...requiredChars]
        for (let i = result.length; i < this.passwordLength(); i++) {
          result.push(pool[getRandomInt(pool.length)]!)
        }
    
        for (let i = result.length - 1; i > 0; i--) {
          const j = getRandomInt(i + 1)
          const temp = result[i]!
          result[i] = result[j]!
          result[j] = temp
        }
    
        return result.join('')
      }
    
      generatePassphrase(): string {
        const chosenWords: string[] = []
        for (let i = 0; i < this.wordCount(); i++) {
          let word = WORD_LIST[getRandomInt(WORD_LIST.length)]!
          if (this.capitalizeWords()) word = word.charAt(0).toUpperCase() + word.slice(1)
          chosenWords.push(word)
        }
        if (this.includeNumberInPassphrase()) {
          chosenWords.push((getRandomInt(90) + 10).toString())
        }
        return chosenWords.join(this.separator())
      }
    
      generatePin(): string {
        const digits: string[] = []
        for (let i = 0; i < this.pinLength(); i++) {
          let nextDigit = getRandomInt(10).toString()
          if (this.avoidPinRepeats() && digits.length > 0 && nextDigit === digits[digits.length - 1]) {
            nextDigit = ((parseInt(nextDigit, 10) + 1 + getRandomInt(8)) % 10).toString()
          }
          digits.push(nextDigit)
        }
        return digits.join('')
      }
    
      regenerateSecret(recordHistory = true): void {
        let secret = ''
        if (this.mode() === 'password') secret = this.generatePassword()
        else if (this.mode() === 'passphrase') secret = this.generatePassphrase()
        else secret = this.generatePin()
    
        this.currentSecret.set(secret)
    
        if (recordHistory && secret) {
          const strength = this.currentStrength()
          const item: HistoryItem = {
            id: `hist-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
            secret,
            mode: this.mode(),
            entropy: strength.entropy,
            strengthLabel: strength.label,
            createdAt: 'Just now',
          }
          this.history.update((prev) => [item, ...prev.slice(0, 7)])
        }
      }
    
      handleManualRegenerate(): void {
        this.isSpinning.set(true)
        this.regenerateSecret(true)
        setTimeout(() => this.isSpinning.set(false), 400)
      }
    
      async copySecret(text: string, isHistory = false, historyId = ''): Promise<void> {
        try {
          if (navigator?.clipboard?.writeText) {
            await navigator.clipboard.writeText(text)
          } else {
            const textarea = document.createElement('textarea')
            textarea.value = text
            document.body.appendChild(textarea)
            textarea.select()
            document.execCommand('copy')
            document.body.removeChild(textarea)
          }
    
          if (isHistory && historyId) {
            this.copiedHistoryId.set(historyId)
            setTimeout(() => {
              if (this.copiedHistoryId() === historyId) this.copiedHistoryId.set(null)
            }, 2000)
          } else {
            this.copied.set(true)
            setTimeout(() => this.copied.set(false), 2000)
          }
        } catch (err) {
          console.error('Failed to copy to clipboard', err)
        }
      }
    
      toggleHistoryReveal(id: string): void {
        this.revealed.update((prev) => ({ ...prev, [id]: !prev[id] }))
      }
    
      clearHistory(): void {
        this.history.set([])
      }
    }
    
  • src/app/components/blocks/password-generator-widget/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/password-generator-widget.json