diff --git a/webapp/src/components/PasswordGeneratorPage.tsx b/webapp/src/components/PasswordGeneratorPage.tsx index 293b27f..76da3ab 100644 --- a/webapp/src/components/PasswordGeneratorPage.tsx +++ b/webapp/src/components/PasswordGeneratorPage.tsx @@ -1,158 +1,77 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; -import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact'; +import { Check, Copy, Download, LoaderCircle, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact'; import { copyTextToClipboard } from '@/lib/clipboard'; -import { EFFLongWordList } from '@/lib/eff-word-list'; import { t } from '@/lib/i18n'; +import { + clampInteger, + defaultGeneratorSettings, + estimateStrength, + generateValue, + normalizeGeneratorSettings, + type EmailMode, + type EmailOptions, + type GeneratorMode, + type GeneratorSettings, + type PassphraseOptions, + type PasswordOptions, + type PinOptions, + type SshKeyOptions, + type UsernameOptions, +} from '@/lib/password-generator'; +import { generateSshKey, type GeneratedSshKey } from '@/lib/ssh-key-generator'; -type GeneratorMode = 'password' | 'passphrase'; +const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v2'; -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 } { +function readSettings(): GeneratorSettings { 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), - }, - }; + const current = localStorage.getItem(SETTINGS_KEY); + if (current) return normalizeGeneratorSettings(JSON.parse(current)); + + // Preserve compatible options for users upgrading from the original generator. + const legacy = JSON.parse(localStorage.getItem('nodewarden.passwordGenerator.settings.v1') || '{}'); + return normalizeGeneratorSettings(legacy); } catch { - return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions }; + return defaultGeneratorSettings; } } -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 [settings, setSettings] = useState(initial); const [seed, setSeed] = useState(0); const [copied, setCopied] = useState(false); + const [sshKey, setSshKey] = useState(null); + const [sshKeyError, setSshKeyError] = useState(''); + const [sshKeyLoading, setSshKeyLoading] = useState(false); - const generated = useMemo( - () => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)), - [mode, passwordOptions, passphraseOptions, seed] + const generated = useMemo(() => settings.mode === 'sshKey' ? sshKey?.fingerprint || '' : generateValue(settings), [settings, seed, sshKey]); + const strength = useMemo( + () => estimateStrength(settings.mode, generated, settings.mode === 'passphrase' ? settings.passphrase.words : undefined), + [generated, settings.mode, settings.passphrase.words], ); - const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]); + const strengthLabel = strength + ? t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][strength - 1]) + : ''; useEffect(() => { try { - localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions })); + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch { // The generator remains fully usable when browser storage is unavailable. } - }, [mode, passwordOptions, passphraseOptions]); + }, [settings]); + + useEffect(() => { + if (settings.mode !== 'sshKey') return; + let cancelled = false; + setSshKeyLoading(true); + setSshKeyError(''); + void generateSshKey({ ...settings.sshKey, comment: '' }) + .then((value) => { if (!cancelled) setSshKey(value); }) + .catch(() => { if (!cancelled) { setSshKey(null); setSshKeyError(t('txt_generator_ssh_error')); } }) + .finally(() => { if (!cancelled) setSshKeyLoading(false); }); + return () => { cancelled = true; }; + }, [settings.mode, settings.sshKey.type, settings.sshKey.rsaLength, seed]); const regenerate = () => { setCopied(false); @@ -160,17 +79,49 @@ export default function PasswordGeneratorPage() { }; const copy = async () => { - await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) }); + const value = settings.mode === 'sshKey' && sshKey ? publicKeyWithComment(sshKey.publicKey, settings.sshKey.comment) : generated; + await copyTextToClipboard(value, { onSuccess: () => setCopied(true), onError: () => setCopied(false) }); window.setTimeout(() => setCopied(false), 1600); }; - const changePasswordOption = (key: K, value: PasswordOptions[K]) => { - setPasswordOptions((current) => ({ ...current, [key]: value })); + const changeMode = (mode: GeneratorMode) => { + setSettings((current) => ({ ...current, mode })); setCopied(false); }; + const changePasswordOption = (key: K, value: PasswordOptions[K]) => { + setSettings((current) => ({ ...current, password: { ...current.password, [key]: value } })); + setCopied(false); + }; + + const changeCharacterType = (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => { + const enabled = ['uppercase', 'lowercase', 'numbers', 'special'].filter((item) => settings.password[item as 'uppercase']); + if (!checked && enabled.length === 1 && enabled[0] === key) return; + changePasswordOption(key, checked); + }; + const changePassphraseOption = (key: K, value: PassphraseOptions[K]) => { - setPassphraseOptions((current) => ({ ...current, [key]: value })); + setSettings((current) => ({ ...current, passphrase: { ...current.passphrase, [key]: value } })); + setCopied(false); + }; + + const changePinOption = (key: K, value: PinOptions[K]) => { + setSettings((current) => ({ ...current, pin: { ...current.pin, [key]: value } })); + setCopied(false); + }; + + const changeUsernameOption = (key: K, value: UsernameOptions[K]) => { + setSettings((current) => ({ ...current, username: { ...current.username, [key]: value } })); + setCopied(false); + }; + + const changeEmailOption = (key: K, value: EmailOptions[K]) => { + setSettings((current) => ({ ...current, email: { ...current.email, [key]: value } })); + setCopied(false); + }; + + const changeSshKeyOption = (key: K, value: SshKeyOptions[K]) => { + setSettings((current) => ({ ...current, sshKey: { ...current.sshKey, [key]: value } })); setCopied(false); }; @@ -178,62 +129,182 @@ export default function PasswordGeneratorPage() {
-
- - -
- {generated} -
-
- {[1, 2, 3, 4].map((level) => )} -
- {strength.label} +
+ {([ + ['password', 'txt_password'], + ['passphrase', 'txt_passphrase'], + ['pin', 'txt_generator_pin'], + ['username', 'txt_generator_username'], + ['email', 'txt_generator_email_alias'], + ['sshKey', 'txt_generator_ssh_key'], + ] as const).map(([mode, label]) => ( + + ))}
+ {settings.mode === 'sshKey' ? ( + + ) : {generated || t('txt_generator_email_required_hint')}} + {settings.mode !== 'sshKey' &&
+ {strength > 0 ? ( + <> +
+ {[1, 2, 3, 4].map((level) => )} +
+ {strengthLabel} + + ) : } + {t('txt_generator_character_count', { count: generated.length })} +
}
- - + +
-

{t('txt_generator_security_note')}

+

{t(settings.mode === 'sshKey' ? 'txt_generator_ssh_security_note' : 'txt_generator_security_note')}

{t('txt_options')}

- {mode === 'password' ? ( + {settings.mode === 'password' && ( + + )} + {settings.mode === 'passphrase' && ( <> - 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('words', value)} /> + + {settings.passphrase.wordList === 'custom' &&