1319 lines
46 KiB
JavaScript
1319 lines
46 KiB
JavaScript
// Password Generator Frontend Application v4.0
|
|
// Complete refactoring with API-driven generator definitions
|
|
// Supports programmatic i18n key derivation and dynamic form generation
|
|
|
|
class PasswordGeneratorApp {
|
|
constructor() {
|
|
this.currentLang = 'en';
|
|
this.translations = {};
|
|
this.generatorDefinitions = []; // ✅ NEW: API-driven definitions
|
|
this.availableWordlists = {};
|
|
this.gridResizeListener = null;
|
|
this.gridState = { currentLayout: 'single', cardCount: 0 };
|
|
this.resizeTimeout = null;
|
|
this.rouletteRuns = new Map();
|
|
this.defaultRouletteIntervalSeconds = 3;
|
|
|
|
this.defaultLanguages = {
|
|
'en': 'English',
|
|
'de': 'Deutsch',
|
|
'de_CH': 'Schwyzerdütsch',
|
|
'fr': 'Français',
|
|
'it': 'Italiano',
|
|
'es': 'Español',
|
|
'pt': 'Portugues',
|
|
'ja': '日本語',
|
|
'zh': '中文'
|
|
};
|
|
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
console.log('🚀 Initializing Password Generator App v4.0');
|
|
|
|
try {
|
|
await this.detectLanguage();
|
|
await this.loadTranslations();
|
|
await this.loadGeneratorsMetadata(); // ✅ NEW: Load from API
|
|
await this.loadAvailableWordlists();
|
|
|
|
this.setupEventListeners();
|
|
this.renderGeneratorCards(); // ✅ Now uses API definitions
|
|
this.updateLanguageSelector();
|
|
this.switchLanguage(this.currentLang);
|
|
this.hideLoadingSpinner();
|
|
this.updateFormFieldsForScreen();
|
|
this.setupGridObserver();
|
|
|
|
// Add resize listener
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(this.resizeTimeout);
|
|
this.resizeTimeout = setTimeout(() => {
|
|
this.updateFormFieldsForScreen();
|
|
}, 250);
|
|
});
|
|
|
|
console.log('✅ Initialization complete');
|
|
} catch (error) {
|
|
console.error('❌ Initialization failed:', error);
|
|
this.showError('Failed to initialize application');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ✅ NEW: Load generator metadata from backend API
|
|
*/
|
|
async loadGeneratorsMetadata() {
|
|
try {
|
|
console.log('🔄 Loading generator metadata from backend...');
|
|
|
|
const response = await fetch('/api/v1/passwords/generators');
|
|
if (!response.ok) {
|
|
throw new Error(`API request failed: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.success && data.data.generators) {
|
|
this.generatorDefinitions = data.data.generators;
|
|
this.defaultRouletteIntervalSeconds = Math.max(
|
|
1,
|
|
Math.min(30, parseInt(data.data.settings?.roulette_interval_seconds ?? 3, 10) || 3)
|
|
);
|
|
console.log(`✅ Loaded ${this.generatorDefinitions.length} generators`);
|
|
|
|
// Log generator summary
|
|
this.generatorDefinitions.forEach(gen => {
|
|
console.log(` 📦 ${gen.id} (v${gen.version}): ${gen.options.length} options`);
|
|
});
|
|
} else {
|
|
throw new Error('Invalid API response format for generators');
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Failed to load generator metadata:', error.message);
|
|
this.generatorDefinitions = [];
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
hideLoadingSpinner() {
|
|
const loadingElement = document.getElementById('initial-loading');
|
|
const generatorsContainer = document.getElementById('generators-container');
|
|
|
|
if (loadingElement) {
|
|
loadingElement.style.display = 'none';
|
|
}
|
|
|
|
if (generatorsContainer) {
|
|
generatorsContainer.style.display = 'grid';
|
|
generatorsContainer.classList.add('generator-grid');
|
|
console.log('✅ Generator grid now visible');
|
|
} else {
|
|
console.error('❌ generators-container element not found in DOM');
|
|
}
|
|
}
|
|
|
|
async loadAvailableWordlists() {
|
|
try {
|
|
console.log('🔄 Loading available wordlists from backend...');
|
|
const response = await fetch('/api/v1/passwords/wordlists');
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API request failed: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.success && data.data.wordlists) {
|
|
this.availableWordlists = data.data.wordlists;
|
|
console.log('✅ Loaded wordlists:', Object.keys(this.availableWordlists));
|
|
} else {
|
|
throw new Error('Invalid API response format');
|
|
}
|
|
} catch (error) {
|
|
console.warn('⚠️ Failed to load wordlists from API:', error.message);
|
|
console.log('🔄 Using fallback wordlist configuration');
|
|
|
|
this.availableWordlists = {
|
|
'en': { name: 'English (Fallback)', description: 'Built-in English word list', available: false },
|
|
'de': { name: 'German (Fallback)', description: 'Built-in German word list', available: false }
|
|
};
|
|
}
|
|
}
|
|
|
|
setupGridObserver() {
|
|
if ('IntersectionObserver' in window) {
|
|
const gridObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const container = entry.target;
|
|
this.updateGridLayout(container);
|
|
}
|
|
});
|
|
}, { rootMargin: '50px' });
|
|
|
|
const container = document.getElementById('generators-container');
|
|
if (container) {
|
|
gridObserver.observe(container);
|
|
}
|
|
}
|
|
}
|
|
|
|
updateGridLayout(container) {
|
|
const cardCount = container.querySelectorAll('.generator-card').length;
|
|
const containerWidth = container.clientWidth;
|
|
|
|
let layout = 'single';
|
|
if (containerWidth >= 1600 && cardCount >= 4) {
|
|
layout = 'quad';
|
|
} else if (containerWidth >= 1200 && cardCount >= 3) {
|
|
layout = 'triple';
|
|
} else if (containerWidth >= 768 && cardCount >= 2) {
|
|
layout = 'dual';
|
|
}
|
|
|
|
if (this.gridState.currentLayout !== layout) {
|
|
this.gridState.currentLayout = layout;
|
|
this.gridState.cardCount = cardCount;
|
|
console.log(`📐 Grid layout: ${layout} (${cardCount} cards)`);
|
|
}
|
|
}
|
|
|
|
updateFormFieldsForScreen() {
|
|
const isMobile = window.innerWidth <= 768;
|
|
const formControls = document.querySelectorAll('.form-control');
|
|
|
|
formControls.forEach(control => {
|
|
if (isMobile) {
|
|
if (control.type !== 'checkbox' && control.type !== 'radio') {
|
|
control.classList.add('form-control--full');
|
|
control.classList.remove('form-control--sm', 'form-control--md', 'form-control--lg');
|
|
}
|
|
} else {
|
|
control.classList.remove('form-control--full');
|
|
|
|
if (control.type === 'number') {
|
|
control.classList.add('form-control--sm');
|
|
} else if (control.tagName === 'SELECT') {
|
|
if (control.dataset.option === 'language' || control.dataset.option === 'wordlist') {
|
|
control.classList.add('form-control--language');
|
|
} else {
|
|
control.classList.add('form-control--md');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async detectLanguage() {
|
|
const browserLang = (navigator.language || 'en').replace('-', '_');
|
|
const supportedLanguages = ['en', 'de', 'de_CH', 'fr', 'it', 'es', 'pt', 'ja', 'zh'];
|
|
|
|
if (supportedLanguages.includes(browserLang)) {
|
|
this.currentLang = browserLang;
|
|
} else if (supportedLanguages.includes(browserLang.substring(0, 2))) {
|
|
this.currentLang = browserLang.substring(0, 2);
|
|
} else {
|
|
this.currentLang = 'en';
|
|
}
|
|
|
|
console.log(`🌍 Language detected: ${this.currentLang}`);
|
|
}
|
|
|
|
async loadTranslations() {
|
|
try {
|
|
const response = await fetch(`/api/v1/locales/${this.currentLang}`);
|
|
if (response.ok) {
|
|
this.translations = await response.json();
|
|
console.log(`✅ Loaded translations for ${this.currentLang}`);
|
|
} else {
|
|
throw new Error(`Failed to load translations: ${response.status}`);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`⚠️ Failed to load ${this.currentLang} translations:`, error.message);
|
|
this.translations = this.getFallbackTranslations();
|
|
}
|
|
}
|
|
|
|
getFallbackTranslations() {
|
|
return {
|
|
"app_title": "Password Generator",
|
|
"generate": "Generate",
|
|
"roulette": "Roulette",
|
|
"stop": "Stop",
|
|
"roulette_interval": "Interval (s)",
|
|
"copy": "Copy",
|
|
"copied_to_clipboard": "Copied to clipboard!",
|
|
"generating": "Generating...",
|
|
"count": "Count",
|
|
"loading_generators": "Loading password generators...",
|
|
"entropy": "Entropy",
|
|
"length": "Length",
|
|
"details": "Details",
|
|
"security_level": "Security level",
|
|
"very_strong": "Very strong",
|
|
"strong": "Strong",
|
|
"adequate": "Adequate",
|
|
"weak": "Weak",
|
|
"very_weak": "Very weak",
|
|
"error_loading_generators": "Failed to load generators"
|
|
};
|
|
}
|
|
|
|
translateSecurityLevel(level) {
|
|
const normalized = String(level ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z]+/g, '_')
|
|
.replace(/^_+|_+$/g, '');
|
|
|
|
const translationKeys = {
|
|
'very_strong': 'very_strong',
|
|
'strong': 'strong',
|
|
'adequate': 'adequate',
|
|
'weak': 'weak',
|
|
'very_weak': 'very_weak',
|
|
};
|
|
|
|
if (translationKeys[normalized]) {
|
|
return this.t(translationKeys[normalized]);
|
|
}
|
|
|
|
return level ? String(level) : this.t('security_level');
|
|
}
|
|
|
|
t(key, params = {}) {
|
|
if (!key || typeof key !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
const candidates = [key];
|
|
|
|
if (key.includes('_choice_')) {
|
|
candidates.push(key.replace('_choice_', '_list_'));
|
|
}
|
|
|
|
if (key.includes('add_number')) {
|
|
candidates.push(key.replace('add_number', 'addNumber'));
|
|
}
|
|
|
|
if (key.includes('number_position')) {
|
|
candidates.push(key.replace('number_position', 'numberPosition'));
|
|
}
|
|
|
|
if (key.includes('_options_group_')) {
|
|
candidates.push('');
|
|
}
|
|
|
|
let translation = '';
|
|
for (const candidate of candidates) {
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
|
|
const value = this.translations[candidate];
|
|
if (value !== undefined && value !== null) {
|
|
translation = value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!translation && key.includes('_options_group_')) {
|
|
return '';
|
|
}
|
|
|
|
if (!translation) {
|
|
translation = key;
|
|
}
|
|
|
|
Object.keys(params).forEach(param => {
|
|
translation = String(translation).replace(`{{${param}}}`, params[param]);
|
|
});
|
|
|
|
return translation;
|
|
}
|
|
|
|
updateLanguageSelector() {
|
|
const languageSelect = document.getElementById('language-select');
|
|
if (!languageSelect) {
|
|
console.warn('⚠️ Language selector not found');
|
|
return;
|
|
}
|
|
|
|
languageSelect.innerHTML = '';
|
|
Object.entries(this.defaultLanguages).forEach(([code, name]) => {
|
|
const option = document.createElement('option');
|
|
option.value = code;
|
|
option.textContent = name;
|
|
option.selected = code === this.currentLang;
|
|
languageSelect.appendChild(option);
|
|
});
|
|
|
|
console.log('✅ Language selector updated');
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Language selector
|
|
const languageSelect = document.getElementById('language-select');
|
|
if (languageSelect) {
|
|
languageSelect.addEventListener('change', (e) => {
|
|
this.switchLanguage(e.target.value);
|
|
});
|
|
}
|
|
|
|
// Event delegation for dynamic content
|
|
document.addEventListener('click', (e) => {
|
|
// Generate button handler
|
|
const generateBtn = e.target.closest('.generate-btn');
|
|
if (generateBtn && !generateBtn.disabled) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const type = generateBtn.dataset.type;
|
|
if (type) {
|
|
this.generatePasswords(type);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Roulette button handler
|
|
const rouletteBtn = e.target.closest('.roulette-btn');
|
|
if (rouletteBtn && !rouletteBtn.disabled) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const type = rouletteBtn.dataset.type;
|
|
if (type) {
|
|
this.toggleRoulette(type);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const intervalIncBtn = e.target.closest('.roulette-interval-inc-btn');
|
|
if (intervalIncBtn) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.adjustRouletteInterval(intervalIncBtn, +1);
|
|
return;
|
|
}
|
|
|
|
const intervalDecBtn = e.target.closest('.roulette-interval-dec-btn');
|
|
if (intervalDecBtn) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.adjustRouletteInterval(intervalDecBtn, -1);
|
|
return;
|
|
}
|
|
|
|
// Copy button handler
|
|
const copyBtn = e.target.closest('.copy-btn');
|
|
if (copyBtn) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const card = copyBtn.closest('.generator-card');
|
|
if (card?.dataset?.type) {
|
|
this.stopRoulette(card.dataset.type);
|
|
}
|
|
const text = copyBtn.dataset.text;
|
|
if (text) {
|
|
this.copyToClipboard(text, copyBtn);
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Form changes
|
|
document.addEventListener('change', (e) => {
|
|
if (e.target.matches('input, select')) {
|
|
this.updateConditionalOptions(e.target);
|
|
}
|
|
});
|
|
|
|
console.log('✅ Event listeners set up');
|
|
}
|
|
|
|
async switchLanguage(lang) {
|
|
this.currentLang = lang;
|
|
await this.loadTranslations();
|
|
this.updateUI();
|
|
console.log(`🌍 Switched to language: ${lang}`);
|
|
}
|
|
|
|
updateUI() {
|
|
// Update translatable elements
|
|
document.querySelectorAll('[data-i18n]').forEach(element => {
|
|
const key = element.dataset.i18n;
|
|
element.textContent = this.t(key);
|
|
});
|
|
|
|
// Update placeholders
|
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
|
|
const key = element.dataset.i18nPlaceholder;
|
|
element.placeholder = this.t(key);
|
|
});
|
|
|
|
// Update titles
|
|
document.querySelectorAll('[data-i18n-title]').forEach(element => {
|
|
const key = element.dataset.i18nTitle;
|
|
element.title = this.t(key);
|
|
});
|
|
|
|
// Update page title
|
|
document.title = this.t('app_title');
|
|
|
|
// Re-render generator cards with new translations
|
|
this.renderGeneratorCards();
|
|
}
|
|
|
|
updateConditionalOptions(changedElement) {
|
|
const card = changedElement.closest('.generator-card');
|
|
if (!card) return;
|
|
|
|
// Generic conditional option handling based on data attributes
|
|
const optionName = changedElement.dataset.option;
|
|
if (!optionName) return;
|
|
|
|
// Find all elements that depend on this option
|
|
const dependentElements = card.querySelectorAll(`[data-depends-on="${optionName}"]`);
|
|
|
|
dependentElements.forEach(dependent => {
|
|
const showWhen = dependent.dataset.showWhen;
|
|
let shouldShow = false;
|
|
|
|
if (changedElement.type === 'checkbox') {
|
|
shouldShow = showWhen === 'checked' ? changedElement.checked : !changedElement.checked;
|
|
} else if (changedElement.tagName === 'SELECT' || changedElement.type === 'text') {
|
|
const value = changedElement.value;
|
|
if (showWhen) {
|
|
shouldShow = showWhen.split(',').includes(value);
|
|
} else {
|
|
shouldShow = value.trim().length > 0;
|
|
}
|
|
}
|
|
|
|
dependent.style.display = shouldShow ? 'block' : 'none';
|
|
if (shouldShow) {
|
|
dependent.classList.add('option-visible');
|
|
} else {
|
|
dependent.classList.remove('option-visible');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* ✅ NEW: Render generator cards from API definitions
|
|
*/
|
|
renderGeneratorCards() {
|
|
const container = document.getElementById('generators-container');
|
|
if (!container) {
|
|
console.error('❌ Container not found');
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
|
|
if (!this.generatorDefinitions || this.generatorDefinitions.length === 0) {
|
|
container.innerHTML = `<div class="error-message">${this.t('error_loading_generators')}</div>`;
|
|
return;
|
|
}
|
|
|
|
console.log(`🎨 Rendering ${this.generatorDefinitions.length} generator cards...`);
|
|
|
|
this.generatorDefinitions.forEach(generator => {
|
|
const card = this.createGeneratorCard(generator);
|
|
container.appendChild(card);
|
|
});
|
|
|
|
console.log('✅ Generator cards rendered');
|
|
}
|
|
|
|
/**
|
|
* ✅ NEW: Create generator card from API definition
|
|
*/
|
|
createGeneratorCard(generator) {
|
|
const card = document.createElement('div');
|
|
card.className = 'generator-card';
|
|
card.dataset.type = generator.id;
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'generator-header';
|
|
|
|
const title = document.createElement('h3');
|
|
title.className = 'generator-title';
|
|
const translatedTitle = this.t(generator.nameKey);
|
|
title.textContent = translatedTitle && translatedTitle !== generator.nameKey
|
|
? translatedTitle
|
|
: (generator.name || generator.id || 'Generator');
|
|
header.appendChild(title);
|
|
|
|
const description = document.createElement('p');
|
|
description.className = 'generator-description';
|
|
const translatedDescription = this.t(generator.descriptionKey);
|
|
description.textContent = translatedDescription && translatedDescription !== generator.descriptionKey
|
|
? translatedDescription
|
|
: (generator.description || '');
|
|
header.appendChild(description);
|
|
|
|
card.appendChild(header);
|
|
|
|
// Options form
|
|
const optionsContainer = document.createElement('div');
|
|
optionsContainer.className = 'generator-options';
|
|
|
|
// Group options by group
|
|
const groupedOptions = this.groupOptions(generator.options);
|
|
|
|
Object.entries(groupedOptions).forEach(([groupName, options]) => {
|
|
const groupDiv = document.createElement('div');
|
|
groupDiv.className = 'option-group';
|
|
groupDiv.dataset.group = groupName;
|
|
|
|
// Group title
|
|
const groupTitle = document.createElement('h4');
|
|
groupTitle.className = 'option-group-title';
|
|
if (options[0].groupTitleKey) {
|
|
const groupTitleKey = options[0].groupTitleKey;
|
|
const translatedGroupTitle = this.t(groupTitleKey);
|
|
groupTitle.textContent = translatedGroupTitle && translatedGroupTitle !== groupTitleKey
|
|
? translatedGroupTitle
|
|
: '';
|
|
} else {
|
|
groupTitle.textContent = '';
|
|
}
|
|
|
|
const resolvedGroupTitle = this.getGroupDisplayTitle(groupName, groupTitle.textContent);
|
|
|
|
if (this.shouldCollapseGroup(groupName)) {
|
|
const collapsible = document.createElement('details');
|
|
collapsible.className = 'option-group-collapsible';
|
|
|
|
const summary = document.createElement('summary');
|
|
summary.className = 'option-group-summary';
|
|
summary.textContent = resolvedGroupTitle;
|
|
collapsible.appendChild(summary);
|
|
|
|
options.forEach(option => {
|
|
const optionElement = this.renderOption(generator.id, option);
|
|
collapsible.appendChild(optionElement);
|
|
});
|
|
|
|
groupDiv.appendChild(collapsible);
|
|
optionsContainer.appendChild(groupDiv);
|
|
return;
|
|
}
|
|
|
|
groupTitle.textContent = resolvedGroupTitle;
|
|
groupDiv.appendChild(groupTitle);
|
|
|
|
// Render each option in group
|
|
options.forEach(option => {
|
|
const optionElement = this.renderOption(generator.id, option);
|
|
groupDiv.appendChild(optionElement);
|
|
});
|
|
|
|
optionsContainer.appendChild(groupDiv);
|
|
});
|
|
|
|
card.appendChild(optionsContainer);
|
|
|
|
// Generate button and results
|
|
const actionsDiv = document.createElement('div');
|
|
actionsDiv.className = 'generator-actions';
|
|
|
|
const generateBtn = document.createElement('button');
|
|
generateBtn.className = 'generate-btn';
|
|
generateBtn.dataset.type = generator.id;
|
|
generateBtn.textContent = this.t('generate');
|
|
actionsDiv.appendChild(generateBtn);
|
|
|
|
const rouletteBtn = document.createElement('button');
|
|
rouletteBtn.className = 'roulette-btn';
|
|
rouletteBtn.dataset.type = generator.id;
|
|
rouletteBtn.textContent = this.t('roulette');
|
|
actionsDiv.appendChild(rouletteBtn);
|
|
|
|
card.appendChild(actionsDiv);
|
|
|
|
const rouletteStatusRow = document.createElement('div');
|
|
rouletteStatusRow.className = 'roulette-status-row';
|
|
rouletteStatusRow.dataset.rouletteIntervalSeconds = String(this.defaultRouletteIntervalSeconds);
|
|
|
|
const rouletteProgressSlot = document.createElement('div');
|
|
rouletteProgressSlot.className = 'roulette-progress-slot';
|
|
rouletteProgressSlot.setAttribute('aria-hidden', 'true');
|
|
|
|
const rouletteProgressBar = document.createElement('div');
|
|
rouletteProgressBar.className = 'roulette-progress-bar';
|
|
rouletteProgressSlot.appendChild(rouletteProgressBar);
|
|
rouletteStatusRow.appendChild(rouletteProgressSlot);
|
|
|
|
const rouletteSpeedControls = document.createElement('div');
|
|
rouletteSpeedControls.className = 'roulette-speed-controls';
|
|
|
|
const incBtn = document.createElement('button');
|
|
incBtn.type = 'button';
|
|
incBtn.className = 'roulette-speed-btn roulette-interval-inc-btn';
|
|
incBtn.dataset.type = generator.id;
|
|
incBtn.textContent = '🐌';
|
|
incBtn.title = this.t('roulette_slow_down');
|
|
rouletteSpeedControls.appendChild(incBtn);
|
|
|
|
const decBtn = document.createElement('button');
|
|
decBtn.type = 'button';
|
|
decBtn.className = 'roulette-speed-btn roulette-interval-dec-btn';
|
|
decBtn.dataset.type = generator.id;
|
|
decBtn.textContent = '🐇';
|
|
decBtn.title = this.t('roulette_speed_up');
|
|
rouletteSpeedControls.appendChild(decBtn);
|
|
|
|
rouletteStatusRow.appendChild(rouletteSpeedControls);
|
|
card.appendChild(rouletteStatusRow);
|
|
|
|
// Results container
|
|
const resultsDiv = document.createElement('div');
|
|
resultsDiv.className = 'password-results';
|
|
resultsDiv.id = `${generator.id}-results`;
|
|
card.appendChild(resultsDiv);
|
|
|
|
return card;
|
|
}
|
|
|
|
/**
|
|
* ✅ NEW: Group options by their group property
|
|
*/
|
|
groupOptions(options) {
|
|
const grouped = {};
|
|
|
|
options.forEach(option => {
|
|
const group = option.group || 'basic';
|
|
if (!grouped[group]) {
|
|
grouped[group] = [];
|
|
}
|
|
grouped[group].push(option);
|
|
});
|
|
|
|
return grouped;
|
|
}
|
|
|
|
shouldCollapseGroup(groupName) {
|
|
const collapsibleGroups = ['advanced', 'ai', 'expert'];
|
|
return collapsibleGroups.includes((groupName || '').toLowerCase());
|
|
}
|
|
|
|
getGroupDisplayTitle(groupName, fallbackTitle) {
|
|
if (fallbackTitle && fallbackTitle.trim().length > 0) {
|
|
return fallbackTitle;
|
|
}
|
|
|
|
const groupTitleKeys = {
|
|
advanced: 'collapse_group_advanced',
|
|
ai: 'collapse_group_ai',
|
|
expert: 'collapse_group_expert',
|
|
basic: 'collapse_group_basic',
|
|
character_set: 'collapse_group_character_set'
|
|
};
|
|
|
|
const titleKey = groupTitleKeys[groupName];
|
|
if (titleKey) {
|
|
const translated = this.t(titleKey);
|
|
if (translated && translated !== titleKey) {
|
|
return translated;
|
|
}
|
|
}
|
|
|
|
return String(groupName || 'Options')
|
|
.replace(/[_-]+/g, ' ')
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
/**
|
|
* ✅ NEW: Render individual option based on type
|
|
*/
|
|
renderOption(generatorId, option) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'form-group';
|
|
wrapper.dataset.optionName = option.name;
|
|
|
|
// Handle special case: count option should be prominent
|
|
if (option.name === 'count') {
|
|
wrapper.classList.add('form-group--count');
|
|
}
|
|
|
|
let input;
|
|
|
|
switch (option.type) {
|
|
case 'integer':
|
|
input = this.createIntegerInput(option);
|
|
break;
|
|
case 'boolean':
|
|
input = this.createBooleanInput(option);
|
|
break;
|
|
case 'string':
|
|
input = this.createStringInput(option);
|
|
break;
|
|
case 'select':
|
|
input = this.createSelectInput(option);
|
|
break;
|
|
default:
|
|
console.warn(`Unknown option type: ${option.type}`);
|
|
return wrapper;
|
|
}
|
|
|
|
input.id = `${generatorId}-${option.name}`;
|
|
input.dataset.option = option.name;
|
|
|
|
// For boolean inputs, wrap differently
|
|
if (option.type === 'boolean') {
|
|
const checkboxWrapper = document.createElement('div');
|
|
checkboxWrapper.className = 'checkbox-wrapper';
|
|
|
|
checkboxWrapper.appendChild(input);
|
|
|
|
const checkboxContent = document.createElement('div');
|
|
checkboxContent.className = 'checkbox-content';
|
|
|
|
const label = document.createElement('label');
|
|
label.htmlFor = input.id;
|
|
const translatedLabel = this.t(option.labelKey);
|
|
label.textContent = translatedLabel && translatedLabel !== option.labelKey
|
|
? translatedLabel
|
|
: (option.label || option.name || 'Option');
|
|
checkboxContent.appendChild(label);
|
|
|
|
if (option.descriptionKey) {
|
|
const help = document.createElement('small');
|
|
help.className = 'form-text text-muted';
|
|
const translatedHelp = this.t(option.descriptionKey);
|
|
help.textContent = translatedHelp && translatedHelp !== option.descriptionKey
|
|
? translatedHelp
|
|
: (option.description || '');
|
|
checkboxContent.appendChild(help);
|
|
}
|
|
|
|
checkboxWrapper.appendChild(checkboxContent);
|
|
|
|
wrapper.appendChild(checkboxWrapper);
|
|
} else {
|
|
// Label first for non-checkbox inputs
|
|
const label = document.createElement('label');
|
|
label.htmlFor = input.id;
|
|
const translatedLabel = this.t(option.labelKey);
|
|
label.textContent = translatedLabel && translatedLabel !== option.labelKey
|
|
? translatedLabel
|
|
: (option.label || option.name || 'Option');
|
|
wrapper.appendChild(label);
|
|
|
|
wrapper.appendChild(input);
|
|
}
|
|
|
|
// Description/help text
|
|
if (option.descriptionKey && option.type !== 'boolean') {
|
|
const help = document.createElement('small');
|
|
help.className = 'form-text text-muted';
|
|
const translatedHelp = this.t(option.descriptionKey);
|
|
help.textContent = translatedHelp && translatedHelp !== option.descriptionKey
|
|
? translatedHelp
|
|
: (option.description || '');
|
|
wrapper.appendChild(help);
|
|
}
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
createIntegerInput(option) {
|
|
const input = document.createElement('input');
|
|
input.type = 'number';
|
|
input.className = 'form-control form-control--sm';
|
|
input.value = option.default;
|
|
|
|
if ('min' in option) input.min = option.min;
|
|
if ('max' in option) input.max = option.max;
|
|
if (option.required) input.required = true;
|
|
|
|
return input;
|
|
}
|
|
|
|
createBooleanInput(option) {
|
|
const input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.className = 'form-check-input';
|
|
input.checked = !!option.default;
|
|
|
|
return input;
|
|
}
|
|
|
|
createStringInput(option) {
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'form-control form-control--md';
|
|
input.value = option.default || '';
|
|
|
|
if ('maxLength' in option) input.maxLength = option.maxLength;
|
|
if ('minLength' in option) input.minLength = option.minLength;
|
|
if (option.required) input.required = true;
|
|
|
|
return input;
|
|
}
|
|
|
|
createSelectInput(option) {
|
|
const select = document.createElement('select');
|
|
select.className = 'form-control form-control--md';
|
|
|
|
if (!option.choices || option.choices.length === 0) {
|
|
console.warn(`No choices for select option: ${option.name}`);
|
|
return select;
|
|
}
|
|
|
|
option.choices.forEach(choice => {
|
|
const opt = document.createElement('option');
|
|
opt.value = choice.value;
|
|
|
|
// ✅ Check if choices should be translated
|
|
if (option.translateChoices === false || !choice.labelKey) {
|
|
// Wordlists and other non-translated choices
|
|
opt.textContent = choice.label || choice.value;
|
|
} else {
|
|
// Translated choices
|
|
const translatedChoice = this.t(choice.labelKey);
|
|
opt.textContent = translatedChoice && translatedChoice !== choice.labelKey
|
|
? translatedChoice
|
|
: (choice.label || choice.value);
|
|
}
|
|
|
|
opt.selected = choice.value === option.default;
|
|
select.appendChild(opt);
|
|
});
|
|
|
|
return select;
|
|
}
|
|
|
|
/**
|
|
* Collect form options for password generation
|
|
*/
|
|
collectFormOptions(card, type) {
|
|
const options = {};
|
|
|
|
// Get all form inputs in this card
|
|
const inputs = card.querySelectorAll('input, select');
|
|
|
|
inputs.forEach(input => {
|
|
const optionName = input.dataset.option;
|
|
if (!optionName) return;
|
|
|
|
switch (input.type) {
|
|
case 'checkbox':
|
|
options[optionName] = input.checked;
|
|
break;
|
|
case 'number':
|
|
case 'range':
|
|
options[optionName] = parseInt(input.value) || 0;
|
|
break;
|
|
case 'text':
|
|
case 'select-one':
|
|
options[optionName] = input.value;
|
|
break;
|
|
}
|
|
});
|
|
|
|
return options;
|
|
}
|
|
|
|
async generatePasswords(type) {
|
|
console.log(`🎯 Generating passwords for type: ${type}`);
|
|
|
|
const card = document.querySelector(`[data-type="${type}"]`);
|
|
if (!card) {
|
|
console.error('Generator card not found:', type);
|
|
return;
|
|
}
|
|
|
|
const generateBtn = card.querySelector('.generate-btn');
|
|
if (!generateBtn) {
|
|
console.error('Generate button not found');
|
|
return;
|
|
}
|
|
|
|
// Disable button and show loading state
|
|
generateBtn.disabled = true;
|
|
const originalText = generateBtn.textContent;
|
|
generateBtn.textContent = this.t('generating');
|
|
|
|
try {
|
|
// Collect options
|
|
const options = this.collectFormOptions(card, type);
|
|
|
|
// Extract count separately
|
|
const countInput = card.querySelector(`[data-option="count"]`);
|
|
const count = countInput ? parseInt(countInput.value) || 1 : 1;
|
|
|
|
console.log(`📝 Options for ${type}:`, options);
|
|
console.log(`📝 Count: ${count}`);
|
|
|
|
// Make API request
|
|
const response = await fetch('/api/v1/passwords/generate', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
type: type,
|
|
count: count,
|
|
options: options
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API request failed: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.data.passwords) {
|
|
this.displayPasswords(card, data.data);
|
|
} else {
|
|
throw new Error(data.message || 'Unknown error');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('❌ Password generation failed:', error);
|
|
this.showError('Failed to generate passwords: ' + error.message);
|
|
} finally {
|
|
// Re-enable button
|
|
generateBtn.disabled = false;
|
|
generateBtn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
async toggleRoulette(type) {
|
|
if (this.rouletteRuns.has(type)) {
|
|
this.stopRoulette(type);
|
|
return;
|
|
}
|
|
|
|
await this.startRoulette(type);
|
|
}
|
|
|
|
async startRoulette(type) {
|
|
const card = document.querySelector(`[data-type="${type}"]`);
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
const run = {
|
|
active: true,
|
|
iterations: 0,
|
|
maxIterations: 12,
|
|
};
|
|
|
|
this.rouletteRuns.set(type, run);
|
|
this.updateRouletteButtonState(card, true);
|
|
|
|
try {
|
|
while (run.active && run.iterations < run.maxIterations) {
|
|
run.iterations += 1;
|
|
await this.generatePasswords(type);
|
|
|
|
if (!run.active || run.iterations >= run.maxIterations) {
|
|
break;
|
|
}
|
|
|
|
const intervalMs = this.getRouletteIntervalMs(card);
|
|
const completed = await this.runRouletteCountdown(type, intervalMs);
|
|
if (!completed) {
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
this.stopRoulette(type);
|
|
}
|
|
}
|
|
|
|
stopRoulette(type) {
|
|
const run = this.rouletteRuns.get(type);
|
|
if (run) {
|
|
run.active = false;
|
|
if (run.countdownFrame) {
|
|
cancelAnimationFrame(run.countdownFrame);
|
|
}
|
|
this.rouletteRuns.delete(type);
|
|
}
|
|
|
|
const card = document.querySelector(`[data-type="${type}"]`);
|
|
if (card) {
|
|
this.updateRouletteButtonState(card, false);
|
|
this.setRouletteProgress(card, 0, false);
|
|
}
|
|
}
|
|
|
|
updateRouletteButtonState(card, isRunning) {
|
|
const rouletteBtn = card.querySelector('.roulette-btn');
|
|
const statusRow = card.querySelector('.roulette-status-row');
|
|
if (!rouletteBtn) {
|
|
return;
|
|
}
|
|
|
|
rouletteBtn.classList.toggle('is-running', isRunning);
|
|
rouletteBtn.textContent = isRunning ? this.t('stop') : this.t('roulette');
|
|
if (statusRow) {
|
|
statusRow.classList.toggle('is-running', isRunning);
|
|
}
|
|
}
|
|
|
|
getRouletteIntervalMs(card) {
|
|
const statusRow = card.querySelector('.roulette-status-row');
|
|
const seconds = statusRow
|
|
? parseInt(statusRow.dataset.rouletteIntervalSeconds || '3', 10)
|
|
: 3;
|
|
const safeSeconds = Number.isFinite(seconds)
|
|
? Math.min(30, Math.max(1, seconds))
|
|
: 3;
|
|
if (statusRow) {
|
|
statusRow.dataset.rouletteIntervalSeconds = String(safeSeconds);
|
|
}
|
|
return safeSeconds * 1000;
|
|
}
|
|
|
|
adjustRouletteInterval(button, deltaSeconds) {
|
|
const card = button.closest('.generator-card');
|
|
const statusRow = card?.querySelector('.roulette-status-row');
|
|
if (!statusRow) {
|
|
return;
|
|
}
|
|
|
|
const current = parseInt(statusRow.dataset.rouletteIntervalSeconds || '3', 10) || 3;
|
|
const next = Math.min(30, Math.max(1, current + deltaSeconds));
|
|
statusRow.dataset.rouletteIntervalSeconds = String(next);
|
|
}
|
|
|
|
setRouletteProgress(card, progress, active) {
|
|
const slot = card.querySelector('.roulette-progress-slot');
|
|
const bar = card.querySelector('.roulette-progress-bar');
|
|
if (!slot || !bar) {
|
|
return;
|
|
}
|
|
|
|
const normalized = Math.max(0, Math.min(1, progress));
|
|
bar.style.width = `${normalized * 100}%`;
|
|
slot.classList.toggle('is-active', !!active);
|
|
}
|
|
|
|
async runRouletteCountdown(type, durationMs) {
|
|
const run = this.rouletteRuns.get(type);
|
|
const card = document.querySelector(`[data-type="${type}"]`);
|
|
if (!run || !card) {
|
|
return false;
|
|
}
|
|
|
|
this.setRouletteProgress(card, 1, true);
|
|
|
|
return await new Promise((resolve) => {
|
|
const startTime = performance.now();
|
|
|
|
const step = (now) => {
|
|
const currentRun = this.rouletteRuns.get(type);
|
|
if (!currentRun || !currentRun.active) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
const elapsed = now - startTime;
|
|
const remainingRatio = 1 - (elapsed / durationMs);
|
|
this.setRouletteProgress(card, remainingRatio, true);
|
|
|
|
if (elapsed >= durationMs) {
|
|
resolve(true);
|
|
return;
|
|
}
|
|
|
|
currentRun.countdownFrame = requestAnimationFrame(step);
|
|
};
|
|
|
|
run.countdownFrame = requestAnimationFrame(step);
|
|
});
|
|
}
|
|
|
|
delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
displayPasswords(card, payload) {
|
|
const resultsDiv = card.querySelector('.password-results');
|
|
if (!resultsDiv) return;
|
|
|
|
resultsDiv.innerHTML = '';
|
|
|
|
const normalizedPayload = Array.isArray(payload)
|
|
? { passwords: payload, explanations: [] }
|
|
: (payload || { passwords: [], explanations: [] });
|
|
|
|
const passwords = Array.isArray(normalizedPayload.passwords)
|
|
? normalizedPayload.passwords
|
|
: [];
|
|
const explanations = Array.isArray(normalizedPayload.explanations)
|
|
? normalizedPayload.explanations
|
|
: [];
|
|
const securityAssessment = typeof normalizedPayload.security_assessment === 'string'
|
|
? normalizedPayload.security_assessment
|
|
: '';
|
|
const rawTechnicalDetails = normalizedPayload.raw_technical_details || normalizedPayload.security_info || null;
|
|
|
|
passwords.forEach((passwordData, index) => {
|
|
const passwordValue = typeof passwordData === 'string'
|
|
? passwordData
|
|
: (passwordData?.password || '');
|
|
|
|
if (!passwordValue) {
|
|
return;
|
|
}
|
|
|
|
const resultItem = document.createElement('div');
|
|
resultItem.className = 'password-result-item';
|
|
|
|
const resultContent = document.createElement('div');
|
|
resultContent.className = 'password-result-content';
|
|
|
|
const passwordText = document.createElement('div');
|
|
passwordText.className = 'password-text';
|
|
passwordText.textContent = passwordValue;
|
|
resultContent.appendChild(passwordText);
|
|
|
|
const securityRow = document.createElement('div');
|
|
securityRow.className = 'password-security-row';
|
|
|
|
const securityLevel = rawTechnicalDetails?.security_level || '';
|
|
const localizedSecurityLevel = this.translateSecurityLevel(securityLevel);
|
|
if (securityLevel || securityAssessment.trim().length > 0) {
|
|
const assessmentBadge = document.createElement('div');
|
|
assessmentBadge.className = 'password-security-badge';
|
|
assessmentBadge.textContent = localizedSecurityLevel || securityAssessment.trim();
|
|
securityRow.appendChild(assessmentBadge);
|
|
}
|
|
|
|
if (rawTechnicalDetails && typeof rawTechnicalDetails === 'object') {
|
|
const entropyValue = rawTechnicalDetails.entropy_bits ?? normalizedPayload.entropy_bits;
|
|
const detailRows = [];
|
|
|
|
if (typeof entropyValue !== 'undefined') {
|
|
detailRows.push({
|
|
label: this.t('entropy'),
|
|
value: `${Number(entropyValue).toFixed(1)} bits`
|
|
});
|
|
}
|
|
|
|
if (rawTechnicalDetails.length) {
|
|
detailRows.push({
|
|
label: this.t('length'),
|
|
value: `${rawTechnicalDetails.length} ${this.t('chars') || 'chars'}`
|
|
});
|
|
}
|
|
|
|
if (rawTechnicalDetails.security_level) {
|
|
detailRows.push({
|
|
label: this.t('security_level'),
|
|
value: this.translateSecurityLevel(rawTechnicalDetails.security_level)
|
|
});
|
|
}
|
|
|
|
if (detailRows.length > 0) {
|
|
const technicalToggle = document.createElement('details');
|
|
technicalToggle.className = 'password-technical-toggle';
|
|
|
|
const summary = document.createElement('summary');
|
|
summary.className = 'password-technical-summary';
|
|
summary.textContent = this.t('details');
|
|
|
|
const technicalBlock = document.createElement('div');
|
|
technicalBlock.className = 'password-technical-details';
|
|
|
|
detailRows.forEach((detail) => {
|
|
const rowEl = document.createElement('div');
|
|
rowEl.className = 'password-technical-row';
|
|
|
|
const labelEl = document.createElement('span');
|
|
labelEl.className = 'password-technical-label';
|
|
labelEl.textContent = detail.label;
|
|
|
|
const valueEl = document.createElement('span');
|
|
valueEl.className = 'password-technical-value';
|
|
valueEl.textContent = detail.value;
|
|
|
|
rowEl.appendChild(labelEl);
|
|
rowEl.appendChild(valueEl);
|
|
technicalBlock.appendChild(rowEl);
|
|
});
|
|
|
|
technicalToggle.appendChild(summary);
|
|
technicalToggle.appendChild(technicalBlock);
|
|
securityRow.appendChild(technicalToggle);
|
|
}
|
|
}
|
|
|
|
if (securityRow.children.length > 0) {
|
|
resultContent.appendChild(securityRow);
|
|
}
|
|
|
|
const explanation = explanations[index];
|
|
if (typeof explanation === 'string' && explanation.trim().length > 0) {
|
|
const explanationText = document.createElement('div');
|
|
explanationText.className = 'password-explanation';
|
|
explanationText.textContent = explanation.trim();
|
|
resultContent.appendChild(explanationText);
|
|
}
|
|
|
|
resultItem.appendChild(resultContent);
|
|
|
|
const copyBtn = document.createElement('button');
|
|
copyBtn.className = 'copy-btn';
|
|
copyBtn.dataset.text = passwordValue;
|
|
copyBtn.textContent = this.t('copy');
|
|
resultItem.appendChild(copyBtn);
|
|
|
|
resultsDiv.appendChild(resultItem);
|
|
});
|
|
}
|
|
|
|
async copyToClipboard(text, button = null) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
if (button) {
|
|
const originalText = button.dataset.originalText || button.textContent || this.t('copy');
|
|
button.dataset.originalText = originalText;
|
|
button.textContent = this.t('copied');
|
|
button.classList.add('success');
|
|
|
|
if (button._copyFeedbackTimeout) {
|
|
clearTimeout(button._copyFeedbackTimeout);
|
|
}
|
|
|
|
button._copyFeedbackTimeout = setTimeout(() => {
|
|
button.textContent = originalText;
|
|
button.classList.remove('success');
|
|
button._copyFeedbackTimeout = null;
|
|
}, 1400);
|
|
}
|
|
|
|
this.showSuccess(this.t('copied_to_clipboard'));
|
|
} catch (error) {
|
|
console.error('❌ Failed to copy:', error);
|
|
this.showError('Failed to copy to clipboard');
|
|
}
|
|
}
|
|
|
|
showSuccess(message) {
|
|
console.log('✅', message);
|
|
// TODO: Implement toast notification
|
|
}
|
|
|
|
showError(message) {
|
|
console.error('❌', message);
|
|
// TODO: Implement toast notification
|
|
}
|
|
}
|
|
|
|
// Initialize app when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new PasswordGeneratorApp();
|
|
});
|
|
} else {
|
|
new PasswordGeneratorApp();
|
|
} |