feat: vault popup with search, autofill and clipboard copy

- Sorts entries by match with the active tab's origin so the right one is at the top.
- Autofill button injects a form-heuristic filler into the active tab via scripting.executeScript.
- Copied credentials auto-clear from the clipboard after 30 seconds.
This commit is contained in:
schmop 2026-04-23 01:33:04 +02:00
parent cad98acbb3
commit 009149a88b
5 changed files with 840 additions and 1 deletions

View File

@ -6,8 +6,12 @@
"background": { "background": {
"scripts": ["build/background.js"] "scripts": ["build/background.js"]
}, },
"permissions": ["storage", "clipboardWrite", "alarms"], "permissions": ["storage", "clipboardWrite", "alarms", "activeTab", "tabs", "scripting"],
"host_permissions": ["https://*/*"], "host_permissions": ["https://*/*"],
"action": {
"default_title": "Passchmop",
"default_popup": "src/popup/popup.html"
},
"options_ui": { "options_ui": {
"page": "src/options/options.html", "page": "src/options/options.html",
"open_in_tab": true "open_in_tab": true

61
src/popup/autofill.ts Normal file
View File

@ -0,0 +1,61 @@
// Injected into the active tab via browser.scripting.executeScript.
// Runs in the page's content-script world, so it only has access to
// DOM + standard web APIs. Keep it self-contained — it's serialized
// by `func` and re-parsed in the target page.
export function fillCredentialsInPage(
username: string,
password: string,
): { ok: boolean; reason?: string } {
const pwInputs = Array.from(document.querySelectorAll('input[type="password"]')) as HTMLInputElement[];
if (pwInputs.length === 0) {
return { ok: false, reason: 'no password field found' };
}
// Prefer a visible password field over an off-screen one.
const visible = (el: HTMLInputElement): boolean => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
let pwEl: HTMLInputElement | null = pwInputs.find(visible) ?? pwInputs[0] ?? null;
if (!pwEl) return { ok: false, reason: 'no password field found' };
// Locate a username field: nearest preceding text/email/tel input in
// the same form. Fall back to the first visible candidate on the page.
const usernameTypes = ['text', 'email', 'tel', 'url', ''];
let userEl: HTMLInputElement | null = null;
const form = pwEl.form;
if (form) {
const inputs = Array.from(form.querySelectorAll('input')) as HTMLInputElement[];
const pwIdx = inputs.indexOf(pwEl);
for (let i = pwIdx - 1; i >= 0; i--) {
const el = inputs[i];
if (!el) continue;
const t = (el.type || 'text').toLowerCase();
if (usernameTypes.includes(t)) { userEl = el; break; }
}
}
if (!userEl) {
const candidates = Array.from(document.querySelectorAll(
'input[type="text"], input[type="email"], input[type="tel"], input[type="url"], input:not([type])',
)) as HTMLInputElement[];
userEl = candidates.find(visible) ?? null;
}
// Use the value-property setter from the element's prototype so that
// React / Vue / Angular controlled inputs observe the change.
const setValue = (el: HTMLInputElement, value: string): void => {
const proto = Object.getPrototypeOf(el);
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
if (desc && desc.set) desc.set.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
};
if (userEl && username) setValue(userEl, username);
setValue(pwEl, password);
// Move focus to the password field so Enter submits the form.
pwEl.focus();
return { ok: true };
}

173
src/popup/popup.css Normal file
View File

@ -0,0 +1,173 @@
:root {
--bg: #1d1f21;
--panel: #282a2e;
--fg: #c5c8c6;
--muted: #888;
--accent: #81a2be;
--danger: #cc6666;
--ok: #b5bd68;
--warn: #f0c674;
--border: #373b41;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 420px;
min-height: 500px;
background: var(--bg);
color: var(--fg);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
}
#root { display: flex; flex-direction: column; min-height: 500px; }
header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
header .headline { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
header .title { font-weight: 700; font-size: 16px; }
header .status {
color: var(--muted);
font-size: 11px;
min-height: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
header .status.err { color: var(--danger); }
header .status.ok { color: var(--ok); }
header .header-right { display: flex; gap: 6px; align-items: center; }
main { flex: 1; padding: 10px 0; display: flex; flex-direction: column; gap: 10px; }
.pad { padding: 4px 14px; display: flex; flex-direction: column; gap: 10px; }
.pad-horizontal { padding: 0 14px; }
footer {
border-top: 1px solid var(--border);
padding: 8px 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
input, textarea {
width: 100%;
padding: 8px 12px;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 5px;
font-size: 14px;
font-family: inherit;
}
textarea { resize: vertical; min-height: 70px; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); }
button {
padding: 8px 14px;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 5px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
button:hover { border-color: var(--accent); }
button.primary {
background: var(--accent);
color: #1d1f21;
border-color: var(--accent);
font-weight: 600;
}
button.primary:hover { filter: brightness(1.1); }
button.ghost {
background: transparent;
border-color: transparent;
color: var(--muted);
}
button.ghost:hover { color: var(--fg); border-color: var(--border); }
button.danger { background: var(--danger); color: white; border-color: var(--danger); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.entry-list {
display: flex;
flex-direction: column;
gap: 5px;
max-height: 380px;
overflow-y: auto;
padding: 0 14px;
}
.entry {
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 3px;
}
.entry.conflict { border-color: var(--warn); }
.entry .title { font-weight: 600; font-size: 14px; }
.entry .sub { color: var(--muted); font-size: 12px; }
.entry .badge {
display: inline-block;
padding: 1px 6px;
background: var(--warn);
color: #1d1f21;
border-radius: 3px;
font-size: 10px;
margin-left: 6px;
font-weight: 600;
}
.entry-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 6px;
align-items: center;
}
.empty {
color: var(--muted);
text-align: center;
padding: 40px 16px;
font-size: 13px;
}
.field { display: flex; flex-direction: column; gap: 4px; }
.field label {
font-size: 12px;
color: var(--muted);
font-weight: 500;
}
.row { display: flex; gap: 6px; align-items: stretch; }
.row > input { flex: 1; }
.row-actions { display: flex; gap: 8px; margin-top: 6px; }
.row-actions > button { flex: 1; }
.input-combo { position: relative; display: flex; }
.input-combo input { flex: 1; padding-right: 30px; }
.input-combo .clear-btn {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
padding: 2px 8px;
background: transparent;
border: none;
color: var(--muted);
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.input-combo .clear-btn:hover { color: var(--fg); }
.copy-feedback { color: var(--ok); font-size: 12px; margin-left: 4px; }
.err { color: var(--danger); font-size: 12px; }

12
src/popup/popup.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Passchmop</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div id="root"></div>
<script src="../../build/popup.js"></script>
</body>
</html>

589
src/popup/popup.ts Normal file
View File

@ -0,0 +1,589 @@
import { api } from '../common/messages.js';
import { fillCredentialsInPage } from './autofill.js';
import type { EntryWithMeta } from '../common/types.js';
const root = document.getElementById('root') as HTMLElement;
type Mode = 'loading' | 'setup' | 'locked' | 'list' | 'edit';
interface EditingEntry {
id: string;
title: string;
url: string;
username: string;
password: string;
notes: string;
created_at?: string;
}
interface View {
mode: Mode;
query: string;
entries: EntryWithMeta[];
editing: EditingEntry | null;
status: string | null;
statusKind: 'err' | 'ok' | null;
currentTabUrl: string;
}
const view: View = {
mode: 'loading',
query: '',
entries: [],
editing: null,
status: null,
statusKind: null,
currentTabUrl: '',
};
// DOM refs for list mode so typing into the search box only re-renders
// the entry list, not the input (which would kill focus).
interface ListUI { wrap: HTMLElement; search: HTMLInputElement; body: HTMLElement; updateBody: () => void }
let listUI: ListUI | null = null;
// Row collapsers for mutually-exclusive expansion. Cleared whenever the
// list body rebuilds.
const rowCollapsers = new Set<() => void>();
// ---- Tiny DOM helper ----
type Children = Array<Node | string | null | false | undefined>;
type HProps = {
class?: string;
title?: string;
placeholder?: string;
type?: string;
value?: string;
on?: Record<string, (e: Event) => void>;
style?: string;
[attr: string]: unknown;
};
function h<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: HProps = {},
...children: (Node | string | null | false | undefined | (Node | string | null | false | undefined)[])[]
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === 'class' && typeof v === 'string') el.className = v;
else if (k === 'on' && v && typeof v === 'object') {
for (const [ev, fn] of Object.entries(v as Record<string, (e: Event) => void>)) {
el.addEventListener(ev, fn);
}
} else if (v !== undefined && v !== null && v !== false) {
el.setAttribute(k, v === true ? '' : String(v));
}
}
const flat: Children = ([] as Children).concat(...(children as Children[]));
for (const c of flat) {
if (c == null || c === false) continue;
el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return el;
}
// ---- Helpers ----
function openOptions(): void {
const url = browser.runtime.getURL('src/options/options.html');
browser.tabs.create({ url });
window.close();
}
async function getActiveTab(): Promise<browser.tabs.Tab | null> {
try {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
return tabs[0] ?? null;
} catch {
return null;
}
}
function safeOrigin(url: string | null | undefined): string | null {
if (!url) return null;
try { return new URL(url).origin; } catch { return null; }
}
function safeHost(url: string | null | undefined): string | null {
if (!url) return null;
try { return new URL(url).hostname; } catch { return null; }
}
// ---- Lifecycle ----
async function boot(): Promise<void> {
const tab = await getActiveTab();
view.currentTabUrl = tab?.url ?? '';
try {
const configured = await api.isConfigured();
if (!configured) { view.mode = 'setup'; render(); return; }
const unlocked = await api.isUnlocked();
if (!unlocked) { view.mode = 'locked'; render(); return; }
view.mode = 'list';
await loadEntries();
render();
await runSync();
} catch (e) {
setStatus(errText(e), 'err');
view.mode = 'locked';
render();
}
}
async function runSync(): Promise<void> {
setStatus('syncing…', null);
try {
await api.sync();
await loadEntries();
refreshList();
setStatus('synced', 'ok');
setTimeout(() => { if (view.statusKind === 'ok') setStatus('', null); }, 1200);
} catch (e) {
setStatus(errText(e), 'err');
}
}
async function loadEntries(): Promise<void> {
view.entries = await api.list();
}
function errText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function setStatus(msg: string | null, kind: 'err' | 'ok' | null): void {
view.status = msg;
view.statusKind = kind;
renderStatus();
}
function renderStatus(): void {
const el = document.querySelector('header .status');
if (!el) return;
el.textContent = view.status || '';
el.className = `status ${view.statusKind || ''}`;
}
// ---- Render ----
function render(): void {
listUI = null;
rowCollapsers.clear();
root.innerHTML = '';
root.appendChild(renderHeader());
const main = h('main');
root.appendChild(main);
switch (view.mode) {
case 'loading': main.appendChild(h('div', { class: 'empty' }, 'loading…')); break;
case 'setup': main.appendChild(renderSetupHint()); break;
case 'locked': main.appendChild(renderUnlock()); break;
case 'list': main.appendChild(renderList()); break;
case 'edit': main.appendChild(renderEdit()); break;
}
if (view.mode === 'list' || view.mode === 'locked') {
root.appendChild(renderFooter());
}
}
function refreshList(): void {
if (view.mode === 'list' && listUI) listUI.updateBody();
else render();
}
function renderHeader(): HTMLElement {
const left = h('div', { class: 'headline' },
h('span', { class: 'title' }, 'Passchmop'),
h('span', { class: 'status' }, view.status || ''),
);
const right = h('div', { class: 'header-right' });
if (view.mode === 'list') {
right.appendChild(
h('button', {
class: 'primary',
title: 'Add entry',
on: { click: () => {
const origin = safeOrigin(view.currentTabUrl) || '';
view.editing = { id: '', title: '', url: origin, username: '', password: '', notes: '' };
view.mode = 'edit';
render();
} },
}, '+ Add'),
);
} else if (view.mode === 'edit') {
right.appendChild(
h('button', {
class: 'ghost',
title: 'Back',
on: { click: () => { view.editing = null; view.mode = 'list'; render(); } },
}, '← Back'),
);
}
return h('header', {}, left, right);
}
function renderFooter(): HTMLElement {
const left = h('div');
const right = h('div');
if (view.mode === 'list') {
left.appendChild(
h('button', {
class: 'ghost',
title: 'Lock vault (requires master password to unlock)',
on: { click: async () => {
await api.lock();
view.mode = 'locked';
render();
} },
}, '🔒 Lock'),
);
}
right.appendChild(
h('button', {
class: 'ghost',
title: 'Settings',
on: { click: openOptions },
}, '⚙ Settings'),
);
return h('footer', {}, left, right);
}
function renderSetupHint(): HTMLElement {
const wrap = h('div', { class: 'pad' });
wrap.appendChild(h('p', {}, 'No vault configured yet.'));
wrap.appendChild(h('button', { class: 'primary', on: { click: openOptions } }, 'Open setup'));
return wrap;
}
function renderUnlock(): HTMLElement {
const wrap = h('div', { class: 'pad' });
const passInput = h('input', { type: 'password', placeholder: 'master password' });
const err = h('div', { class: 'err' });
const btn = h('button', { class: 'primary' }, '🔓 Unlock');
const submit = async (): Promise<void> => {
btn.disabled = true;
err.textContent = '';
try {
await api.unlock(passInput.value);
await loadEntries();
view.mode = 'list';
render();
await runSync();
} catch (e) {
err.textContent = errText(e);
} finally {
btn.disabled = false;
}
};
btn.addEventListener('click', submit);
passInput.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') submit(); });
wrap.appendChild(h('div', { class: 'field' }, h('label', {}, 'Master password'), passInput));
wrap.appendChild(h('div', { class: 'row-actions' }, btn));
wrap.appendChild(err);
setTimeout(() => passInput.focus(), 0);
return wrap;
}
function renderList(): HTMLElement {
const wrap = h('div');
const search = h('input', { placeholder: 'search…', value: view.query });
wrap.appendChild(h('div', { class: 'pad-horizontal' }, search));
const body = h('div', { class: 'list-body' });
wrap.appendChild(body);
const updateBody = (): void => {
body.replaceChildren(buildListBody());
};
search.addEventListener('input', () => {
view.query = search.value;
updateBody();
});
listUI = { wrap, search, body, updateBody };
updateBody();
return wrap;
}
function buildListBody(): HTMLElement {
rowCollapsers.clear();
const q = view.query.toLowerCase().trim();
const filtered = view.entries.filter(e => matchesQuery(e, q));
const sorted = sortEntries(filtered);
if (sorted.length === 0) {
return h('div', { class: 'empty' },
view.entries.length ? 'no matches' : 'no entries yet — click "+ Add" to create one');
}
const list = h('div', { class: 'entry-list' });
for (const e of sorted) list.appendChild(renderEntryRow(e));
return list;
}
function matchesQuery(e: EntryWithMeta, q: string): boolean {
if (!q) return true;
const hay = `${e.title} ${e.url} ${e.username}`.toLowerCase();
return hay.includes(q);
}
// Sort priority:
// 0. entry URL origin === current tab origin
// 1. partial URL / hostname overlap with current tab
// 2. alphabetical by title (then username)
function sortEntries(entries: EntryWithMeta[]): EntryWithMeta[] {
const currentUrl = view.currentTabUrl || '';
const currentOrigin = safeOrigin(currentUrl);
const currentHost = safeHost(currentUrl);
const score = (e: EntryWithMeta): number => {
const entryOrigin = safeOrigin(e.url);
if (currentOrigin && entryOrigin && entryOrigin === currentOrigin) return 0;
if (currentUrl && e.url) {
const a = e.url.toLowerCase();
const b = currentUrl.toLowerCase();
if (a.includes(b) || b.includes(a)) return 1;
const hostA = safeHost(e.url);
if (currentHost && hostA && (hostA.endsWith(currentHost) || currentHost.endsWith(hostA))) return 1;
}
return 2;
};
return entries.slice().sort((a, b) => {
const sa = score(a);
const sb = score(b);
if (sa !== sb) return sa - sb;
const ka = (a.title || a.username || '').toLowerCase();
const kb = (b.title || b.username || '').toLowerCase();
return ka.localeCompare(kb);
});
}
function renderEntryRow(e: EntryWithMeta): HTMLElement {
const row = h('div', { class: `entry ${e._conflict ? 'conflict' : ''}` });
const title = h('div', { class: 'title' }, e.title || '(untitled)');
if (e._conflict) title.appendChild(h('span', { class: 'badge' }, 'conflict'));
row.appendChild(title);
row.appendChild(h('div', { class: 'sub' }, [e.username, e.url].filter(Boolean).join(' · ') || '—'));
const actions = h('div', { class: 'entry-actions' });
let expanded = false;
const feedback = h('span', { class: 'copy-feedback' });
actions.appendChild(h('button', {
on: { click: (ev: Event) => { ev.stopPropagation(); copy(e.username, feedback, 'username copied'); } },
}, 'Copy user'));
actions.appendChild(h('button', {
on: { click: (ev: Event) => { ev.stopPropagation(); copy(e.password, feedback, 'password copied'); } },
}, 'Copy pass'));
actions.appendChild(h('button', {
class: 'primary',
title: 'Autofill username and password in the active tab',
on: { click: async (ev: Event) => {
ev.stopPropagation();
await autofill(e, feedback);
} },
}, '✎ Autofill'));
actions.appendChild(h('button', {
class: 'ghost',
on: { click: (ev: Event) => {
ev.stopPropagation();
view.editing = {
id: e.id, title: e.title, url: e.url, username: e.username,
password: e.password, notes: e.notes, created_at: e.created_at,
};
view.mode = 'edit';
render();
} },
}, '✏ Edit'));
actions.appendChild(h('button', {
class: 'danger',
on: { click: async (ev: Event) => {
ev.stopPropagation();
if (!confirm(`Delete "${e.title || 'entry'}"?`)) return;
try {
await api.delete(e.id, e._file);
await loadEntries();
refreshList();
} catch (err) { setStatus(errText(err), 'err'); }
} },
}, '🗑 Delete'));
actions.appendChild(feedback);
const collapse = (): void => {
if (!expanded) return;
actions.remove();
expanded = false;
rowCollapsers.delete(collapse);
};
const expand = (): void => {
for (const c of Array.from(rowCollapsers)) c();
row.appendChild(actions);
expanded = true;
rowCollapsers.add(collapse);
};
row.addEventListener('click', () => {
if (expanded) collapse();
else expand();
});
return row;
}
async function copy(text: string, feedbackEl: HTMLElement, msg: string): Promise<void> {
try {
await navigator.clipboard.writeText(text || '');
feedbackEl.textContent = msg;
setTimeout(() => { feedbackEl.textContent = ''; }, 1500);
setTimeout(async () => {
try {
const current = await navigator.clipboard.readText();
if (current === text) await navigator.clipboard.writeText('');
} catch { /* readText often blocked; ignore */ }
}, 30000);
} catch {
feedbackEl.textContent = 'copy failed';
}
}
// firefox-webext-browser types `func` as () => void. The real API serialises
// the function's return value into `InjectionResult.result`; wrap here so we
// keep a typed return path instead of scattering `as any`.
const execInTab = browser.scripting.executeScript as unknown as <A extends unknown[], R>(
opts: { target: { tabId: number }; func: (...args: A) => R; args: A },
) => Promise<Array<{ result?: R }>>;
async function autofill(entry: EntryWithMeta, feedbackEl: HTMLElement): Promise<void> {
const tab = await getActiveTab();
if (!tab?.id) { feedbackEl.textContent = 'no active tab'; return; }
try {
const results = await execInTab({
target: { tabId: tab.id },
func: fillCredentialsInPage,
args: [entry.username, entry.password],
});
const first = results[0];
const result = first?.result;
if (!result) {
feedbackEl.textContent = 'autofill failed';
return;
}
if (!result.ok) {
feedbackEl.textContent = result.reason || 'autofill failed';
return;
}
feedbackEl.textContent = 'filled';
window.close();
} catch (e) {
feedbackEl.textContent = errText(e);
}
}
function renderEdit(): HTMLElement {
const e = view.editing!;
const isNew = !e.id;
const wrap = h('div', { class: 'pad' });
const title = inputField('Title', e.title);
const url = inputField('URL', e.url);
const clearUrl = h('button', {
class: 'ghost',
title: 'Clear URL',
on: { click: () => { url.el.value = ''; url.el.focus(); } },
}, 'Clear');
const user = inputField('Username', e.username);
const pw = inputField('Password', e.password, 'password');
const showBtn = h('button', { class: 'ghost', title: 'Show / hide' }, 'show');
showBtn.addEventListener('click', () => {
pw.el.type = pw.el.type === 'password' ? 'text' : 'password';
showBtn.textContent = pw.el.type === 'password' ? 'show' : 'hide';
});
const genBtn = h('button', { class: 'ghost', title: 'Generate a strong password' }, '↻ gen');
genBtn.addEventListener('click', () => { pw.el.value = randomPassword(20); });
const notes = h('textarea', { placeholder: 'notes' });
notes.value = e.notes || '';
wrap.appendChild(title.wrap);
wrap.appendChild(h('div', { class: 'field' },
h('label', {}, 'URL'),
h('div', { class: 'row' }, url.el, clearUrl),
));
wrap.appendChild(user.wrap);
wrap.appendChild(h('div', { class: 'field' },
h('label', {}, 'Password'),
h('div', { class: 'row' }, pw.el, showBtn, genBtn),
));
wrap.appendChild(h('div', { class: 'field' }, h('label', {}, 'Notes'), notes));
const err = h('div', { class: 'err' });
const save = h('button', { class: 'primary' }, isNew ? '✓ Create' : '💾 Save');
save.addEventListener('click', async () => {
save.disabled = true;
err.textContent = '';
try {
await api.put({
id: e.id || undefined,
title: title.el.value,
url: url.el.value,
username: user.el.value,
password: pw.el.value,
notes: notes.value,
created_at: e.created_at,
});
await loadEntries();
view.editing = null;
view.mode = 'list';
render();
} catch (ex) {
err.textContent = errText(ex);
} finally {
save.disabled = false;
}
});
const cancel = h('button', {
class: 'ghost',
on: { click: () => { view.editing = null; view.mode = 'list'; render(); } },
}, '✕ Cancel');
wrap.appendChild(h('div', { class: 'row-actions' }, save, cancel));
wrap.appendChild(err);
return wrap;
}
interface Field { el: HTMLInputElement; wrap: HTMLElement }
function inputField(label: string, value: string | undefined, type = 'text'): Field {
const el = h('input', { type });
el.value = value || '';
const wrap = h('div', { class: 'field' }, h('label', {}, label), el);
return { el, wrap };
}
function randomPassword(len = 20): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_=+';
const buf = crypto.getRandomValues(new Uint8Array(len));
let out = '';
for (const b of buf) {
const ch = alphabet[b % alphabet.length];
if (ch) out += ch;
}
return out;
}
boot();