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": {
|
||||
"scripts": ["build/background.js"]
|
||||
},
|
||||
"permissions": ["storage", "clipboardWrite", "alarms"],
|
||||
"permissions": ["storage", "clipboardWrite", "alarms", "activeTab", "tabs", "scripting"],
|
||||
"host_permissions": ["https://*/*"],
|
||||
"action": {
|
||||
"default_title": "Passchmop",
|
||||
"default_popup": "src/popup/popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "src/options/options.html",
|
||||
"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