import { useEffect, useMemo, useState } from 'preact/hooks'; import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact'; import { copyTextToClipboard } from '@/lib/clipboard'; import { EFFLongWordList } from '@/lib/eff-word-list'; import { t } from '@/lib/i18n'; type GeneratorMode = 'password' | 'passphrase'; interface PasswordOptions { length: number; uppercase: boolean; lowercase: boolean; numbers: boolean; special: boolean; minNumbers: number; minSpecial: number; avoidAmbiguous: boolean; } interface PassphraseOptions { words: number; separator: string; capitalize: boolean; includeNumber: boolean; } const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1'; const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz'; const DIGITS = '0123456789'; const SPECIAL = '!@#$%^&*'; const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']); const defaultPasswordOptions: PasswordOptions = { length: 14, uppercase: true, lowercase: true, numbers: true, special: false, minNumbers: 1, minSpecial: 1, avoidAmbiguous: false, }; const defaultPassphraseOptions: PassphraseOptions = { words: 6, separator: '-', capitalize: false, includeNumber: false, }; function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback; } function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } { try { const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial; passphrase: Partial }>; return { mode: stored.mode === 'passphrase' ? 'passphrase' : 'password', password: { ...defaultPasswordOptions, ...stored.password, length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length), minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers), minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial), }, passphrase: { ...defaultPassphraseOptions, ...stored.passphrase, words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words), separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1), }, }; } catch { return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions }; } } function randomIndex(length: number): number { const range = 0x1_0000_0000; const upperBound = Math.floor(range / length) * length; const buffer = new Uint32Array(1); do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound); return buffer[0] % length; } function pick(characters: string): string { return characters[randomIndex(characters.length)]; } function shuffle(value: string[]): string[] { for (let index = value.length - 1; index > 0; index -= 1) { const next = randomIndex(index + 1); [value[index], value[next]] = [value[next], value[index]]; } return value; } function filtered(characters: string, avoidAmbiguous: boolean): string { return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters; } function generatePassword(options: PasswordOptions): string { const sets: Array<{ chars: string; minimum: number }> = []; if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 }); if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 }); if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers }); if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial }); if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 }); const minimumLength = sets.reduce((total, set) => total + set.minimum, 0); const length = Math.max(options.length, minimumLength, 5); const allCharacters = sets.map((set) => set.chars).join(''); const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars))); while (characters.length < length) characters.push(pick(allCharacters)); return shuffle(characters).join(''); } function generatePassphrase(options: PassphraseOptions): string { const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]); if (options.capitalize) { for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1); } if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10)); return words.join(options.separator); } function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } { const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2))); return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) }; } export default function PasswordGeneratorPage() { const initial = useMemo(readSettings, []); const [mode, setMode] = useState(initial.mode); const [passwordOptions, setPasswordOptions] = useState(initial.password); const [passphraseOptions, setPassphraseOptions] = useState(initial.passphrase); const [seed, setSeed] = useState(0); const [copied, setCopied] = useState(false); const generated = useMemo( () => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)), [mode, passwordOptions, passphraseOptions, seed] ); const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]); useEffect(() => { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions })); } catch { // The generator remains fully usable when browser storage is unavailable. } }, [mode, passwordOptions, passphraseOptions]); const regenerate = () => { setCopied(false); setSeed((value) => value + 1); }; const copy = async () => { await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) }); window.setTimeout(() => setCopied(false), 1600); }; const changePasswordOption = (key: K, value: PasswordOptions[K]) => { setPasswordOptions((current) => ({ ...current, [key]: value })); setCopied(false); }; const changePassphraseOption = (key: K, value: PassphraseOptions[K]) => { setPassphraseOptions((current) => ({ ...current, [key]: value })); setCopied(false); }; return (
{generated}
{[1, 2, 3, 4].map((level) => )}
{strength.label}

{t('txt_generator_security_note')}

{t('txt_options')}

{mode === 'password' ? ( <> changePasswordOption('length', value)} />
{t('txt_generator_character_types')} changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} /> changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} /> changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} /> {passwordOptions.numbers && changePasswordOption('minNumbers', value)} />} changePasswordOption('special', checked)} label={t('txt_generator_special')} /> {passwordOptions.special && changePasswordOption('minSpecial', value)} />}
changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} /> ) : ( <> changePassphraseOption('words', value)} />
changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} /> changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
)}
); } function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) { return ; } function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) { const id = `generator-stepper-${props.id}`; const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback)); return (
setValue(Number(event.currentTarget.value))} />
); }