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:
parent
cad98acbb3
commit
009149a88b
|
|
@ -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
61
src/popup/autofill.ts
Normal 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
173
src/popup/popup.css
Normal 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
12
src/popup/popup.html
Normal 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
589
src/popup/popup.ts
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user