353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
/// Inline generator panel — mounts inside a parent element (form root or
|
|
/// settings section). Trigger button gets aria-expanded toggled. Preview
|
|
/// updates live as knobs change (150ms debounce). Kind toggle swaps
|
|
/// between Random + BIP39 knob sets. Action row varies by context:
|
|
/// fill-field shows cancel+use; configure-defaults shows only save-default.
|
|
|
|
import { sendMessage } from '../../shared/state';
|
|
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
|
import { colorizePassword } from '../../shared/password-coloring';
|
|
|
|
interface UiKnobs {
|
|
kind: 'random' | 'bip39';
|
|
// Random
|
|
length: number;
|
|
lower: boolean;
|
|
upper: boolean;
|
|
digits: boolean;
|
|
symbols: boolean;
|
|
symbolCharset: 'safe_only' | 'extended' | 'custom';
|
|
customSymbols: string;
|
|
// BIP39
|
|
wordCount: number;
|
|
separator: string;
|
|
capitalization: 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
|
|
}
|
|
|
|
function knobsFromRequest(req: GeneratorRequest): UiKnobs {
|
|
const defaults: UiKnobs = {
|
|
kind: 'random',
|
|
length: 20, lower: true, upper: true, digits: true, symbols: true,
|
|
symbolCharset: 'safe_only', customSymbols: '',
|
|
wordCount: 5, separator: ' ', capitalization: 'lower',
|
|
};
|
|
if (req.kind === 'random') {
|
|
return {
|
|
...defaults,
|
|
kind: 'random',
|
|
length: req.length,
|
|
lower: req.classes.lower,
|
|
upper: req.classes.upper,
|
|
digits: req.classes.digits,
|
|
symbols: req.classes.symbols,
|
|
symbolCharset: req.symbol_charset.kind,
|
|
customSymbols: req.symbol_charset.kind === 'custom' ? req.symbol_charset.value : '',
|
|
};
|
|
}
|
|
return {
|
|
...defaults,
|
|
kind: 'bip39',
|
|
wordCount: req.word_count,
|
|
separator: req.separator,
|
|
capitalization: req.capitalization,
|
|
};
|
|
}
|
|
|
|
function requestFromKnobs(knobs: UiKnobs): GeneratorRequest {
|
|
if (knobs.kind === 'random') {
|
|
return {
|
|
kind: 'random',
|
|
length: knobs.length,
|
|
classes: {
|
|
lower: knobs.lower, upper: knobs.upper,
|
|
digits: knobs.digits, symbols: knobs.symbols,
|
|
},
|
|
symbol_charset:
|
|
knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } :
|
|
knobs.symbolCharset === 'extended' ? { kind: 'extended' } :
|
|
{ kind: 'custom', value: knobs.customSymbols },
|
|
};
|
|
}
|
|
return {
|
|
kind: 'bip39',
|
|
word_count: knobs.wordCount,
|
|
separator: knobs.separator,
|
|
capitalization: knobs.capitalization,
|
|
};
|
|
}
|
|
|
|
export type GeneratorPanelContext = 'fill-field' | 'configure-defaults';
|
|
|
|
export interface OpenPanelOpts {
|
|
parent: HTMLElement; // mount target (form root or settings section)
|
|
trigger: HTMLElement; // ✨ button (aria-expanded gets toggled here)
|
|
initial: GeneratorRequest;
|
|
context: GeneratorPanelContext;
|
|
onPicked?: (value: string) => void; // required when context === 'fill-field'
|
|
}
|
|
|
|
let activePanel: {
|
|
host: HTMLElement;
|
|
trigger: HTMLElement;
|
|
cleanup: () => void;
|
|
} | null = null;
|
|
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
export function openGeneratorPanel(opts: OpenPanelOpts): void {
|
|
closeGeneratorPanel();
|
|
|
|
const knobs = knobsFromRequest(opts.initial);
|
|
let currentPreview = '';
|
|
|
|
const host = document.createElement('div');
|
|
host.className = 'gen-panel';
|
|
opts.parent.appendChild(host);
|
|
|
|
opts.trigger.setAttribute('aria-expanded', 'true');
|
|
|
|
const escHandler = (e: KeyboardEvent): void => {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
closeGeneratorPanel();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', escHandler);
|
|
|
|
const cleanup = (): void => {
|
|
document.removeEventListener('keydown', escHandler);
|
|
if (debounceTimer !== null) {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = null;
|
|
}
|
|
opts.trigger.setAttribute('aria-expanded', 'false');
|
|
host.remove();
|
|
};
|
|
|
|
activePanel = { host, trigger: opts.trigger, cleanup };
|
|
|
|
const refreshPreview = (): void => {
|
|
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(async () => {
|
|
debounceTimer = null;
|
|
const request = requestFromKnobs(knobs);
|
|
const msg = knobs.kind === 'random'
|
|
? { type: 'generate_password' as const, request }
|
|
: { type: 'generate_passphrase' as const, request };
|
|
const resp = await sendMessage(msg);
|
|
if (resp.ok) {
|
|
const d = resp.data as { password?: string; passphrase?: string };
|
|
currentPreview = d.password ?? d.passphrase ?? '';
|
|
const el = host.querySelector('.preview__value');
|
|
if (el) {
|
|
el.textContent = '';
|
|
el.appendChild(colorizePassword(currentPreview));
|
|
}
|
|
updateValidation();
|
|
}
|
|
}, 150);
|
|
};
|
|
|
|
const updateValidation = (): void => {
|
|
const useBtn = host.querySelector('#gen-use') as HTMLButtonElement | null;
|
|
if (!useBtn) return;
|
|
const noClass = knobs.kind === 'random'
|
|
&& !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols);
|
|
useBtn.disabled = noClass;
|
|
};
|
|
|
|
const wireInner = (): void => {
|
|
host.querySelector('#gen-kind-random')?.addEventListener('click', () => {
|
|
knobs.kind = 'random'; render();
|
|
});
|
|
host.querySelector('#gen-kind-bip39')?.addEventListener('click', () => {
|
|
knobs.kind = 'bip39'; render();
|
|
});
|
|
|
|
host.querySelector('#gen-length')?.addEventListener('input', (e) => {
|
|
knobs.length = Number((e.target as HTMLInputElement).value);
|
|
const out = host.querySelector('#gen-length-val');
|
|
if (out) out.textContent = String(knobs.length);
|
|
refreshPreview();
|
|
});
|
|
|
|
for (const { id, key } of [
|
|
{ id: 'gen-lower', key: 'lower' as const },
|
|
{ id: 'gen-upper', key: 'upper' as const },
|
|
{ id: 'gen-digits', key: 'digits' as const },
|
|
{ id: 'gen-symbols', key: 'symbols' as const },
|
|
]) {
|
|
host.querySelector(`#${id}`)?.addEventListener('change', (e) => {
|
|
knobs[key] = (e.target as HTMLInputElement).checked;
|
|
updateValidation();
|
|
refreshPreview();
|
|
});
|
|
}
|
|
|
|
host.querySelectorAll<HTMLButtonElement>('[data-symbol-charset]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
knobs.symbolCharset = btn.dataset.symbolCharset as UiKnobs['symbolCharset'];
|
|
render();
|
|
});
|
|
});
|
|
|
|
host.querySelector('#gen-word-count')?.addEventListener('input', (e) => {
|
|
knobs.wordCount = Number((e.target as HTMLInputElement).value);
|
|
const out = host.querySelector('#gen-word-count-val');
|
|
if (out) out.textContent = String(knobs.wordCount);
|
|
refreshPreview();
|
|
});
|
|
|
|
host.querySelectorAll<HTMLButtonElement>('[data-separator]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
knobs.separator = btn.dataset.separator ?? ' ';
|
|
render();
|
|
});
|
|
});
|
|
|
|
host.querySelectorAll<HTMLButtonElement>('[data-capitalization]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
knobs.capitalization = btn.dataset.capitalization as UiKnobs['capitalization'];
|
|
render();
|
|
});
|
|
});
|
|
|
|
host.querySelector('.preview__regen')?.addEventListener('click', () => {
|
|
refreshPreview();
|
|
});
|
|
|
|
host.querySelector('#gen-use')?.addEventListener('click', () => {
|
|
opts.onPicked?.(currentPreview);
|
|
closeGeneratorPanel();
|
|
});
|
|
|
|
host.querySelector('#gen-cancel')?.addEventListener('click', () => {
|
|
closeGeneratorPanel();
|
|
});
|
|
|
|
host.querySelector('#gen-save-default')?.addEventListener('click', async () => {
|
|
const link = host.querySelector('#gen-save-default') as HTMLElement | null;
|
|
const settingsResp = await sendMessage({ type: 'get_vault_settings' });
|
|
if (!settingsResp.ok) return;
|
|
const settings = (settingsResp.data as { settings: VaultSettings }).settings;
|
|
settings.generator_defaults = requestFromKnobs(knobs);
|
|
const updateResp = await sendMessage({ type: 'update_vault_settings', settings });
|
|
if (!updateResp.ok) return;
|
|
if (link) {
|
|
link.querySelector('.save-link__toast')?.remove();
|
|
const toast = document.createElement('span');
|
|
toast.className = 'save-link__toast';
|
|
toast.textContent = '✓ saved';
|
|
link.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 1500);
|
|
}
|
|
});
|
|
};
|
|
|
|
const render = (): void => {
|
|
host.innerHTML = buildInnerHtml(knobs, opts.context);
|
|
wireInner();
|
|
refreshPreview();
|
|
};
|
|
|
|
render();
|
|
}
|
|
|
|
export function closeGeneratorPanel(): void {
|
|
if (activePanel === null) return;
|
|
activePanel.cleanup();
|
|
activePanel = null;
|
|
}
|
|
|
|
export function isGeneratorPanelOpen(): boolean {
|
|
return activePanel !== null;
|
|
}
|
|
|
|
// --- HTML builders ---
|
|
|
|
function buildInnerHtml(knobs: UiKnobs, context: GeneratorPanelContext): string {
|
|
const actionRow = context === 'fill-field'
|
|
? `<button class="save-link" id="gen-save-default" type="button">↑ save these as default</button>
|
|
<button class="btn" id="gen-cancel" type="button">cancel</button>
|
|
<button class="btn btn-primary" id="gen-use" type="button">use</button>`
|
|
: `<button class="save-link" id="gen-save-default" type="button">↑ save these as default</button>`;
|
|
|
|
return `
|
|
<div class="panel-toggle">
|
|
<button id="gen-kind-random" type="button" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
|
|
<button id="gen-kind-bip39" type="button" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
|
|
</div>
|
|
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
|
|
<div class="preview">
|
|
<span class="preview__value"></span>
|
|
<button type="button" class="preview__regen" title="regenerate">↻</button>
|
|
</div>
|
|
<div class="actions">
|
|
${actionRow}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function buildRandomKnobs(k: UiKnobs): string {
|
|
return `
|
|
<div class="knob">
|
|
<span class="knob__label">length</span>
|
|
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="knob__slider">
|
|
<span class="knob__value" id="gen-length-val">${k.length}</span>
|
|
</div>
|
|
<div class="classes">
|
|
<label><input type="checkbox" id="gen-lower" ${k.lower ? 'checked' : ''}> lowercase</label>
|
|
<label><input type="checkbox" id="gen-upper" ${k.upper ? 'checked' : ''}> uppercase</label>
|
|
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
|
|
<label><input type="checkbox" id="gen-symbols" ${k.symbols ? 'checked' : ''}> symbols</label>
|
|
</div>
|
|
<details class="more">
|
|
<summary>more ▾</summary>
|
|
<div class="more__advanced">
|
|
<div class="knob">
|
|
<span class="knob__label">symbols</span>
|
|
<div class="panel-toggle" style="flex:1;">
|
|
<button data-symbol-charset="safe_only" type="button" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
|
|
<button data-symbol-charset="extended" type="button" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
function buildBip39Knobs(k: UiKnobs): string {
|
|
const sepChip = (label: string, sep: string) => `
|
|
<button data-separator="${sep}" type="button" class="${k.separator === sep ? 'active' : ''}">${label}</button>
|
|
`;
|
|
const capChip = (label: string, val: string) => `
|
|
<button data-capitalization="${val}" type="button" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
|
|
`;
|
|
return `
|
|
<div class="knob">
|
|
<span class="knob__label">words</span>
|
|
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="knob__slider">
|
|
<span class="knob__value" id="gen-word-count-val">${k.wordCount}</span>
|
|
</div>
|
|
<div class="knob" style="align-items:flex-start;">
|
|
<span class="knob__label">separator</span>
|
|
<div class="panel-toggle" style="flex:1;">
|
|
${sepChip('space', ' ')}
|
|
${sepChip('-', '-')}
|
|
${sepChip('_', '_')}
|
|
${sepChip('.', '.')}
|
|
${sepChip(':', ':')}
|
|
</div>
|
|
</div>
|
|
<div class="knob" style="align-items:flex-start;">
|
|
<span class="knob__label">case</span>
|
|
<div class="panel-toggle" style="flex:1;">
|
|
${capChip('lower', 'lower')}
|
|
${capChip('upper', 'upper')}
|
|
${capChip('first', 'first_of_each')}
|
|
${capChip('title', 'title')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|