diff --git a/extension/LICENSE.txt b/extension/LICENSE.txt deleted file mode 100644 index c382718..0000000 --- a/extension/LICENSE.txt +++ /dev/null @@ -1,17 +0,0 @@ -OpenNIT Vault – Browser-Erweiterung -Copyright (C) 2025 Friederich Loheide - -Dieses Programm ist freie Software: Sie koennen es unter den Bedingungen -der GNU Affero General Public License, Version 3, weitergeben und/oder -veraendern. - -Die Veroeffentlichung erfolgt in der Hoffnung, dass es nuetzlich ist, -jedoch OHNE JEDE GEWAEHRLEISTUNG - sogar ohne die implizite Gewaehr- -leistung der Marktreife oder der Eignung fuer einen bestimmten Zweck. -Einzelheiten regelt die GNU Affero General Public License. - -Lizenztext: https://www.gnu.org/licenses/agpl-3.0.html -Quelltext: https://opennit.de - -Der vollstaendige Lizenztext liegt der OpenNIT-Installation als Datei -LICENSE bei und kann ueber die oben genannte Adresse bezogen werden. diff --git a/extension/background.js b/extension/background.js index 01b988c..86113c5 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,5 +1,7 @@ 'use strict'; +importScripts('urlmatch.js'); + // ── Cache ────────────────────────────────────────────────────────────────── let cachedEntries = null; let cacheTime = 0; @@ -13,13 +15,13 @@ async function getServerUrl() { return c.serverUrl || null; } -// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ────────────── -// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh). +// ── Zugangstoken beschaffen (SSO) ────────────────────────────────────────── +// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den +// rotierenden Refresh-Token erneuert. async function getAccessToken() { const cfg = await new Promise(r => chrome.storage.local.get( - ['serverUrl', 'apiToken', 'apiRefreshToken', 'apiRefreshExpiresAt'], r)); + ['serverUrl', 'apiRefreshToken', 'apiRefreshExpiresAt'], r)); if (!cfg.serverUrl) return null; - if (cfg.apiToken) return cfg.apiToken; // manueller Token if (!cfg.apiRefreshToken) return null; // nicht angemeldet const sess = await chrome.storage.session.get(['accessToken', 'accessExpiresAt']); if (sess.accessToken && sess.accessExpiresAt && Date.now() < sess.accessExpiresAt - 30000) { @@ -100,11 +102,15 @@ async function setUnlockedLocal(dur) { } async function clearUnlocked() { await chrome.storage.session.remove('unlock'); + await chrome.storage.local.remove('__armedTotp'); + clearPendingFills(); cachedEntries = null; cacheTime = 0; faviconCache.clear(); try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ } } async function onServerLocked() { await chrome.storage.session.remove('unlock'); + await chrome.storage.local.remove('__armedTotp'); + clearPendingFills(); cachedEntries = null; cacheTime = 0; } async function doUnlock(pin) { @@ -147,6 +153,67 @@ async function fetchEntries(force = false) { return { entries: null, locked: false }; } +/** + * Legt einen persönlichen Eintrag im Tresor an. + * + * Läuft bewusst über `apiFetch`, damit derselbe Zugang wie für alle übrigen + * Aufrufe gilt, inklusive automatischer Erneuerung des Access-Tokens. + * + * @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields + * @return {Promise<{ok:boolean,id?:number,locked?:boolean,error?:string}>} + */ +async function createEntry(fields) { + if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; + const body = new URLSearchParams(); + ['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? '')); + return writeEntry('/api/vault/extension/entries', body.toString()); +} + +/** + * Ändert einen bestehenden Eintrag. Ein leeres Passwortfeld lässt das gespeicherte + * Passwort unangetastet; 2FA-Secret und Ordner bleiben serverseitig erhalten. + * + * @param {number|string} entryId + * @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields + * @return {Promise<{ok:boolean,locked?:boolean,error?:string}>} + */ +async function updateEntry(entryId, fields) { + if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; + const body = new URLSearchParams(); + ['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? '')); + return writeEntry(`/api/vault/extension/entries/${entryId}`, body.toString()); +} + +/** + * Löscht einen Eintrag (persönlich oder Team, sofern Schreibrecht besteht). + * + * @param {number|string} entryId + * @return {Promise<{ok:boolean,locked?:boolean,error?:string}>} + */ +async function deleteEntry(entryId) { + if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' }; + return writeEntry(`/api/vault/extension/entries/${entryId}/delete`, ''); +} + +// Gemeinsame Auswertung der schreibenden Endpunkte. +async function writeEntry(path, body) { + try { + const res = await apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body, + }); + if (!res) return { ok: false, error: 'Nicht konfiguriert.' }; + if (res.status === 423) { await onServerLocked(); return { ok: false, locked: true, error: 'Tresor gesperrt.' }; } + const data = await res.json().catch(() => ({})); + if (data.ok) { + cachedEntries = null; cacheTime = 0; + return { ok: true, id: data.id }; + } + return { ok: false, error: data.error || 'Fehler beim Speichern.' }; + } catch (e) { return { ok: false, error: 'Verbindungsfehler.' }; } +} + async function fetchPassword(entryId) { if (!(await isUnlocked())) return null; try { @@ -188,21 +255,28 @@ async function fetchFavicon(entryId) { } catch (e) { return null; } } -function matchUrl(entryUrl, pageUrl) { - if (!entryUrl) return false; - let pHost; - try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; } - return String(entryUrl).split('\n').some(line => { - const raw = line.trim(); - if (!raw) return false; - try { - const eu = raw.includes('://') ? raw : 'https://' + raw; - let eHost = new URL(eu).hostname.replace(/^www\./, ''); - if (eHost.startsWith('*.')) eHost = eHost.slice(2); - return pHost === eHost || pHost.endsWith('.' + eHost); - } catch { return false; } - }); + +// ── Ausstehendes Ausfüllen (mehrstufiger Login) ───────────────────────────── +// Bei Logins, die Benutzername und Passwort auf zwei Schritte verteilen, muss das +// Passwort den Seitenwechsel überdauern. Es bleibt dafür ausschließlich im +// Speicher des Service Workers – niemals in `chrome.storage`, das auf die +// Festplatte geschrieben wird. Je Tab ein Auftrag, mit harter Verfallszeit. +const PENDING_FILL_TTL = 30 * 1000; +const pendingFills = new Map(); // tabId -> { id, pw, user, ts } + +function setPendingFill(tabId, data) { + if (tabId == null) return; + pendingFills.set(tabId, Object.assign({ ts: Date.now() }, data)); } +function takePendingFill(tabId) { + if (tabId == null) return null; + const p = pendingFills.get(tabId); + if (!p) return null; + pendingFills.delete(tabId); + return (Date.now() - p.ts > PENDING_FILL_TTL) ? null : p; +} +function clearPendingFills() { pendingFills.clear(); } +chrome.tabs.onRemoved.addListener(tabId => pendingFills.delete(tabId)); // ── Zwischenablage automatisch leeren (Offscreen) ─────────────────────────── async function scheduleClipClear(text) { @@ -211,19 +285,22 @@ async function scheduleClipClear(text) { pendingClip = text || ''; chrome.alarms.create('clipClear', { delayInMinutes: 0.5 }); } -async function clearClipboard() { +// Schreibt über das Offscreen-Dokument. Dieser Weg funktioniert auch dann, wenn +// die Seite selbst keinen Zugriff bekommt – etwa ohne frische Nutzerinteraktion. +async function writeClipboard(text) { try { if (!chrome.offscreen) return; const has = chrome.offscreen.hasDocument ? await chrome.offscreen.hasDocument() : false; if (!has) { await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['CLIPBOARD'], - justification: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.', + justification: 'Zugangsdaten in die Zwischenablage legen und nach kurzer Zeit wieder entfernen.', }); } - await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' }); + await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: text || '' }); } catch (e) { /* ignore */ } } +async function clearClipboard() { await writeClipboard(''); } // ── Status prüfen (Bearer) ────────────────────────────────────────────────── async function checkStatus() { @@ -247,11 +324,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'GET_MATCHING_ENTRIES') { fetchEntries().then(r => { - const matched = (r.entries || []).filter(e => matchUrl(e.url, msg.url)); + const matched = (r.entries || []).filter(e => VaultUrl.matches(e.url, msg.url)); sendResponse({ entries: matched, locked: r.locked }); }); return true; } + if (msg.type === 'CREATE_ENTRY') { createEntry(msg.entry || {}).then(sendResponse); return true; } + if (msg.type === 'UPDATE_ENTRY') { updateEntry(msg.id, msg.entry || {}).then(sendResponse); return true; } + if (msg.type === 'DELETE_ENTRY') { deleteEntry(msg.id).then(sendResponse); return true; } if (msg.type === 'GET_PASSWORD') { fetchPassword(msg.id).then(password => sendResponse({ password })); return true; } if (msg.type === 'GET_TOTP') { fetchTotp(msg.id).then(result => sendResponse(result)); return true; } if (msg.type === 'GET_FAVICON') { fetchFavicon(msg.id).then(dataUrl => sendResponse({ dataUrl })); return true; } @@ -261,11 +341,26 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'DO_UNLOCK') { doUnlock(msg.pin || '').then(sendResponse); return true; } if (msg.type === 'LOCK_NOW') { clearUnlocked().then(() => sendResponse({ ok: true })); return true; } + if (msg.type === 'SET_PENDING_FILL') { + setPendingFill(sender.tab?.id, { id: msg.id, pw: msg.pw || '', user: msg.user || '' }); + sendResponse({ ok: true }); + return true; + } + if (msg.type === 'TAKE_PENDING_FILL') { sendResponse({ fill: takePendingFill(sender.tab?.id) }); return true; } + if (msg.type === 'CLIP_WRITE') { writeClipboard(msg.text || '').then(() => sendResponse({ ok: true })); return true; } if (msg.type === 'SCHEDULE_CLIP_CLEAR') { scheduleClipClear(msg.text || ''); sendResponse({ ok: true }); return true; } if (msg.type === 'CHECK_STATUS') { checkStatus().then(sendResponse); return true; } if (msg.type === 'CLEAR_CACHE') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); sendResponse({ ok: true }); return true; } }); +// Der manuell eingetragene API-Token wird nicht mehr unterstützt; ein aus einer +// früheren Version übernommener Wert wird beim Update aus dem Speicher entfernt. +// Der manuelle API-Token entfaellt, und ein ausstehender Fuellauftrag liegt nicht +// mehr im Speicher auf der Platte – Reste frueherer Versionen hier entfernen. +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.remove(['apiToken', '__pendingFill']); +}); + // Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout. chrome.alarms.create('clearCache', { periodInMinutes: 5 }); chrome.alarms.onAlarm.addListener(async (alarm) => { diff --git a/extension/content.js b/extension/content.js index 667e6cf..7687cde 100644 --- a/extension/content.js +++ b/extension/content.js @@ -14,6 +14,9 @@ const DROPDOWN_ID = '__vault_dropdown__'; let appLabel = 'Vault'; let currentField = null; let showGen = 0; +let typedField = null; // Feld, in das der Nutzer zuletzt selbst getippt hat +let fillingInProgress = false; // unterdrückt die Tipp-Erkennung beim Autofill +let pendingFillInFlight = false; // der Auftrag wird beim Abholen verbraucht – nur einmal gleichzeitig // ── Heuristik-Muster ──────────────────────────────────────────────────────── const RE_USER = /(user(name|id)?|login|logon|sign[-_ ]?in|account|konto|benutzer|kennung|anmeld|e[-_ ]?mail|email|mail|uid|userid|handle|identifier|ident\b|loginid)/i; @@ -28,7 +31,11 @@ function lc(s) { return String(s || '').toLowerCase(); } function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } function vaultHue(s) { s = String(s || '?'); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; } function vaultClipCopy(text) { - navigator.clipboard.writeText(text).catch(() => {}); + // Ohne frische Nutzerinteraktion verweigert der Browser den direkten Zugriff; + // dann schreibt der Hintergrund über das Offscreen-Dokument. + navigator.clipboard.writeText(text).catch(() => { + try { chrome.runtime.sendMessage({ type: 'CLIP_WRITE', text: text }); } catch (e) { /* ignore */ } + }); try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ } } function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } } @@ -117,6 +124,28 @@ function isUsernameField(el) { return RE_USER.test(s) && !RE_USER_NEG.test(s); } +// Felder, die eine eigene Vorschlagsliste unter sich aufklappen (ARIA-Combobox, +// ). Dort verdeckt unser Dropdown die Treffer der Seite. +function hasOwnSuggestionList(el) { + if (!el) return false; + if (attr(el, 'list')) return true; // datalist + const isCombo = lc(attr(el, 'role')) === 'combobox' + || !!(el.closest && el.closest('[role="combobox"]')); + if (!isCombo) return false; + const auto = lc(attr(el, 'aria-autocomplete')); + if (auto === 'list' || auto === 'both') return true; + // Verweist die Combobox auf ein eigenes Listenelement bzw. meldet ihren + // Auf-/Zuklapp-Zustand, hat sie eine eigene Trefferliste. + return !!(attr(el, 'aria-controls') || attr(el, 'aria-owns') || attr(el, 'aria-expanded')); +} + +// Vom Seitenautor ausdrücklich als Login-Feld ausgezeichnet – wiegt schwerer als +// die Combobox-Erkennung, damit echte Anmeldeformulare weiter Vorschläge bekommen. +function isDeclaredLoginField(el) { + const a = ac(el); + return a.includes('username') || a.includes('password') || a.includes('one-time-code'); +} + function isLoginField(el) { return isPasswordField(el) || isUsernameField(el) || isOtpField(el); } function fieldKind(el) { if (isPasswordField(el)) return 'password'; @@ -178,31 +207,12 @@ function findOtpFields(ref) { return collectInputs(scopeOf(ref)).filter(el => isVisible(el) && isOtpField(el)); } -// ── URL-Matching ──────────────────────────────────────────────────────────── -function normalizeHost(raw) { - if (!raw) return ''; - try { - const s = raw.includes('://') ? raw : 'https://' + raw; - return new URL(s).hostname.replace(/^www\./, '').toLowerCase(); - } catch { return lc(raw).replace(/^www\./, ''); } -} -function matchUrl(entryUrls, pageUrl) { - const pageHost = normalizeHost(pageUrl); - if (!pageHost) return false; - const urls = typeof entryUrls === 'string' ? entryUrls.split('\n') : [entryUrls]; - return urls.some(u => { - let eh = normalizeHost((u || '').trim()); - if (!eh) return false; - if (eh.startsWith('*.')) eh = eh.slice(2); - return pageHost === eh || pageHost.endsWith('.' + eh); - }); -} - // ── Events ────────────────────────────────────────────────────────────────── function init() { document.addEventListener('focusin', onFocusIn, true); document.addEventListener('focusout', onFocusOut, true); document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('input', onInput, true); document.addEventListener('keydown', onKeyDown, true); document.addEventListener('click', onDocClick, true); window.addEventListener('scroll', repositionDrop, true); @@ -227,27 +237,44 @@ function fillFromPopup(msg) { if (userField && msg.username) setFieldValue(userField, msg.username); if (passField && msg.password) setFieldValue(passField, msg.password); - else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } }); + else if (msg.password) chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: msg.id, pw: msg.password, user: msg.username || '' }); if (msg.has_totp && msg.id != null) { - const otps = findOtpFields(passField || userField || document.body); - chrome.runtime.sendMessage({ type: 'GET_TOTP', id: msg.id }, t => { - if (!t || !t.code) return; - if (otps.length) distributeOtp(otps, t.code); - vaultClipCopy(t.code); - showTotpNotification(t.code, t.remaining); - }); + armTotp(msg.id); + deliverTotp(msg.id, findOtpFields(passField || userField || document.body)); } } -function onFocusIn(e) { maybeShow(e.target); } +function onFocusIn(e) { + // Beim Betreten des 2FA-Feldes einen frischen Code bereitlegen. + if (isOtpField(e.target) && isVisible(e.target)) serveArmedTotp([e.target], false); + maybeShow(e.target); +} function onPointerDown(e) { const drop = document.getElementById(DROPDOWN_ID); if (drop && drop.contains(e.target)) return; maybeShow(e.target); } + +// Sobald der Nutzer selbst tippt, gehört der Platz unter dem Feld der Seite – +// dort erscheint typischerweise deren eigene Suche. Das Feld bleibt bis zum +// Leeren gesperrt, damit ein erneuter Klick das Dropdown nicht zurückholt. +function onInput(e) { + const el = e.target; + if (fillingInProgress || !el || !el.value) { + if (el && !el.value && typedField === el) typedField = null; + return; + } + typedField = el; + if (el === currentField) hideDrop(); +} +function isTypingSuppressed(el) { return el === typedField && !!el.value; } + function maybeShow(el) { if (!isLoginField(el) || !isVisible(el)) return; + // Eigene Vorschlagsliste der Seite hat Vorrang. + if (hasOwnSuggestionList(el) && !isDeclaredLoginField(el)) return; + if (isTypingSuppressed(el)) return; currentField = el; showSuggestions(el); } @@ -290,7 +317,7 @@ function showSuggestions(field) { const mode = fieldKind(field) === 'otp' ? 'otp' : 'login'; chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => { if (gen !== showGen) return; - let entries = (resp && resp.entries || []).filter(e => matchUrl(e.url, location.href)); + let entries = (resp && resp.entries || []).filter(e => VaultUrl.matches(e.url, location.href)); if (mode === 'otp') entries = entries.filter(e => e.has_totp); if (!entries.length) { hideDrop(); return; } if (document.contains(field) && isVisible(field)) renderDrop(field, entries, mode); @@ -443,17 +470,66 @@ async function fillEntry(entry, focused) { if (userField && entry.username) setFieldValue(userField, entry.username); if (passField) setFieldValue(passField, pw); - else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } }); + else chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: entry.id, pw: pw, user: entry.username || '' }); if (entry.has_totp) { - const otps = findOtpFields(passField || userField || focused); - chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, t => { - if (!t || !t.code) return; - if (otps.length) distributeOtp(otps, t.code); + // Der 2FA-Schritt folgt oft erst nach dem Absenden – ggf. auf der nächsten + // Seite. Deshalb den Eintrag vormerken, damit dort ein frischer Code + // bereitgestellt werden kann. + armTotp(entry.id); + deliverTotp(entry.id, findOtpFields(passField || userField || focused)); + } +} + +// ── 2FA-Code für den nächsten Schritt bereitlegen ─────────────────────────── +// Nach dem Ausfüllen eines Eintrags mit 2FA bleibt der Eintrag kurz vorgemerkt. +// Taucht danach ein 2FA-Feld auf – auf derselben oder einer Folgeseite –, landet +// ein frischer Code in der Zwischenablage, sodass Strg+V genügt. +const TOTP_ARM_TTL = 5 * 60 * 1000; // Vormerkung nach dem Ausfüllen +const TOTP_RECOPY_MS = 5000; // Mindestabstand zwischen zwei Kopiervorgängen +let lastTotpCopy = 0; +let totpRequestPending = false; // Fokus und Seiten-Scan können gleichzeitig auslösen +const totpServedFields = new WeakSet(); + +function armTotp(entryId) { + try { chrome.storage.local.set({ __armedTotp: { id: entryId, ts: Date.now() } }); } catch (e) { /* ignore */ } +} + +function deliverTotp(entryId, fields, done) { + chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entryId }, t => { + if (t && t.code) { + if (fields && fields.length) distributeOtp(fields, t.code); + lastTotpCopy = Date.now(); vaultClipCopy(t.code); showTotpNotification(t.code, t.remaining); - }); - } + } + if (done) done(); + }); +} + +/** + * Automatischer Weg: nur kopieren und anzeigen. Ins Feld geschrieben wird ein Code + * ausschließlich, wenn der Nutzer den Eintrag selbst ausgewählt hat. + * + * @param {Element[]} fields erkannte 2FA-Felder + * @param {boolean} oncePerField true beim automatischen Auftauchen (je Feld einmal), + * false beim Fokussieren – dort darf nach Ablauf des + * Codes erneut ein frischer kopiert werden. + */ +function serveArmedTotp(fields, oncePerField) { + const relevant = oncePerField ? fields.filter(f => !totpServedFields.has(f)) : fields; + if (!relevant.length || totpRequestPending) return; + if (Date.now() - lastTotpCopy < TOTP_RECOPY_MS) return; + totpRequestPending = true; + chrome.storage.local.get(['__armedTotp', 'totpAutoCopy'], res => { + const armed = res.__armedTotp; + if (res.totpAutoCopy === false || !armed || Date.now() - armed.ts > TOTP_ARM_TTL) { + totpRequestPending = false; + return; + } + relevant.forEach(f => totpServedFields.add(f)); + deliverTotp(armed.id, null, () => { totpRequestPending = false; }); + }); } function fillOtp(field, entry) { @@ -479,6 +555,7 @@ function distributeOtp(fields, code) { } function setFieldValue(field, value) { + fillingInProgress = true; try { field.focus({ preventScroll: true }); const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement) @@ -491,26 +568,47 @@ function setFieldValue(field, value) { field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); field.dispatchEvent(new Event('blur', { bubbles: true })); } catch {} + fillingInProgress = false; } -// ── Mehrstufiger Login: Passwort/User nach dem Erscheinen befüllen ─────────── -const _obs = new MutationObserver(() => { - chrome.storage.local.get(['__pendingFill'], result => { - const p = result.__pendingFill; - if (!p || Date.now() - p.ts > 30000) return; - const pw = collectInputs(document).filter(f => isVisible(f) && isPasswordField(f)); - if (!pw.length) return; +// ── Mehrstufiger Login: Passwort/2FA nach dem Erscheinen bedienen ──────────── +// Der Scan ist entkoppelt vom Mutations-Takt: eine Seite kann pro Sekunde +// hunderte Mutationen erzeugen, die Feldsuche läuft davon unabhängig gedrosselt. +const SCAN_DEBOUNCE_MS = 250; +let scanTimer = null; + +function scanForPendingWork() { + const inputs = collectInputs(document).filter(isVisible); + + const otps = inputs.filter(isOtpField); + if (otps.length) serveArmedTotp(otps, true); + + const pw = inputs.filter(isPasswordField); + if (!pw.length || pendingFillInFlight) return; + pendingFillInFlight = true; + chrome.runtime.sendMessage({ type: 'TAKE_PENDING_FILL' }, resp => { + pendingFillInFlight = false; + const p = resp && resp.fill; + if (!p) return; pw.forEach(f => setFieldValue(f, p.pw)); if (p.user) { const uf = findUsernameField(pw[0]); if (uf && !uf.value) setFieldValue(uf, p.user); } - chrome.storage.local.remove('__pendingFill'); }); +} + +const _obs = new MutationObserver(() => { + if (scanTimer) return; + scanTimer = setTimeout(() => { scanTimer = null; scanForPendingWork(); }, SCAN_DEBOUNCE_MS); }); try { _obs.observe(document.documentElement, { childList: true, subtree: true }); } catch {} +// Bei einem Seitenwechsel steht das 2FA-Feld oft schon im ersten Rendering. +scanForPendingWork(); // ── TOTP-Benachrichtigung (unten rechts) ──────────────────────────────────── +const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert) + function showTotpNotification(code, remaining) { const ID = '__vault_totp_notif__'; const old = document.getElementById(ID); @@ -531,18 +629,20 @@ function showTotpNotification(code, remaining) { + '2FA-Code kopiert' + '