Store-Build automatisch aus OpenNIT aktualisiert
This commit is contained in:
parent
baf13c865f
commit
a032dd5b13
8 changed files with 295 additions and 487 deletions
17
extension/LICENSE.txt
Normal file
17
extension/LICENSE.txt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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.
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
importScripts('urlmatch.js');
|
||||
|
||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||
let cachedEntries = null;
|
||||
let cacheTime = 0;
|
||||
|
|
@ -15,13 +13,13 @@ async function getServerUrl() {
|
|||
return c.serverUrl || null;
|
||||
}
|
||||
|
||||
// ── Zugangstoken beschaffen (SSO) ──────────────────────────────────────────
|
||||
// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den
|
||||
// rotierenden Refresh-Token erneuert.
|
||||
// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ──────────────
|
||||
// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh).
|
||||
async function getAccessToken() {
|
||||
const cfg = await new Promise(r => chrome.storage.local.get(
|
||||
['serverUrl', 'apiRefreshToken', 'apiRefreshExpiresAt'], r));
|
||||
['serverUrl', 'apiToken', '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) {
|
||||
|
|
@ -102,15 +100,11 @@ 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) {
|
||||
|
|
@ -153,67 +147,6 @@ 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 {
|
||||
|
|
@ -255,28 +188,21 @@ async function fetchFavicon(entryId) {
|
|||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
|
||||
// ── 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 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; }
|
||||
});
|
||||
}
|
||||
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) {
|
||||
|
|
@ -285,22 +211,19 @@ async function scheduleClipClear(text) {
|
|||
pendingClip = text || '';
|
||||
chrome.alarms.create('clipClear', { delayInMinutes: 0.5 });
|
||||
}
|
||||
// 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) {
|
||||
async function clearClipboard() {
|
||||
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: 'Zugangsdaten in die Zwischenablage legen und nach kurzer Zeit wieder entfernen.',
|
||||
justification: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.',
|
||||
});
|
||||
}
|
||||
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: text || '' });
|
||||
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
async function clearClipboard() { await writeClipboard(''); }
|
||||
|
||||
// ── Status prüfen (Bearer) ──────────────────────────────────────────────────
|
||||
async function checkStatus() {
|
||||
|
|
@ -324,14 +247,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||
}
|
||||
if (msg.type === 'GET_MATCHING_ENTRIES') {
|
||||
fetchEntries().then(r => {
|
||||
const matched = (r.entries || []).filter(e => VaultUrl.matches(e.url, msg.url));
|
||||
const matched = (r.entries || []).filter(e => matchUrl(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; }
|
||||
|
|
@ -341,26 +261,11 @@ 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) => {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ 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;
|
||||
|
|
@ -31,11 +28,7 @@ function lc(s) { return String(s || '').toLowerCase(); }
|
|||
function esc(s) { return String(s || '').replace(/&/g, '&').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) {
|
||||
// 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 */ }
|
||||
});
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } }
|
||||
|
|
@ -124,28 +117,6 @@ function isUsernameField(el) {
|
|||
return RE_USER.test(s) && !RE_USER_NEG.test(s);
|
||||
}
|
||||
|
||||
// Felder, die eine eigene Vorschlagsliste unter sich aufklappen (ARIA-Combobox,
|
||||
// <input list>). 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';
|
||||
|
|
@ -207,12 +178,31 @@ 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);
|
||||
|
|
@ -237,44 +227,27 @@ function fillFromPopup(msg) {
|
|||
|
||||
if (userField && msg.username) setFieldValue(userField, msg.username);
|
||||
if (passField && msg.password) setFieldValue(passField, msg.password);
|
||||
else if (msg.password) chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: msg.id, pw: msg.password, user: msg.username || '' });
|
||||
else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } });
|
||||
|
||||
if (msg.has_totp && msg.id != null) {
|
||||
armTotp(msg.id);
|
||||
deliverTotp(msg.id, findOtpFields(passField || userField || document.body));
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 onFocusIn(e) { 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);
|
||||
}
|
||||
|
|
@ -317,7 +290,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 => VaultUrl.matches(e.url, location.href));
|
||||
let entries = (resp && resp.entries || []).filter(e => matchUrl(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);
|
||||
|
|
@ -470,66 +443,17 @@ async function fillEntry(entry, focused) {
|
|||
|
||||
if (userField && entry.username) setFieldValue(userField, entry.username);
|
||||
if (passField) setFieldValue(passField, pw);
|
||||
else chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: entry.id, pw: pw, user: entry.username || '' });
|
||||
else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } });
|
||||
|
||||
if (entry.has_totp) {
|
||||
// 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();
|
||||
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);
|
||||
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) {
|
||||
|
|
@ -555,7 +479,6 @@ function distributeOtp(fields, code) {
|
|||
}
|
||||
|
||||
function setFieldValue(field, value) {
|
||||
fillingInProgress = true;
|
||||
try {
|
||||
field.focus({ preventScroll: true });
|
||||
const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement)
|
||||
|
|
@ -568,47 +491,26 @@ function setFieldValue(field, value) {
|
|||
field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||
field.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
} catch {}
|
||||
fillingInProgress = false;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
// ── 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;
|
||||
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);
|
||||
|
|
@ -629,20 +531,18 @@ function showTotpNotification(code, remaining) {
|
|||
+ '<span style="color:#adb5bd;font-size:10px;flex:1;">2FA-Code kopiert</span></div>'
|
||||
+ '<div style="font-family:monospace;font-size:18px;font-weight:700;letter-spacing:.12em;">' + esc(formatted) + '</div>'
|
||||
+ '<div style="display:flex;align-items:center;gap:7px;"><div style="flex:1;height:3px;background:rgba(255,255,255,.15);border-radius:2px;overflow:hidden;">'
|
||||
+ '<div id="__vault_totp_bar" style="height:100%;background:#28a745;width:' + Math.min(100, remaining / TOTP_PERIOD * 100) + '%;transition:width 1s linear;"></div></div>'
|
||||
+ '<div id="__vault_totp_bar" style="height:100%;background:#28a745;width:' + (remaining / 30 * 100) + '%;transition:width 1s linear;"></div></div>'
|
||||
+ '<span id="__vault_totp_t" style="font-size:10px;color:#adb5bd;min-width:22px;text-align:right;">' + remaining + 's</span></div>';
|
||||
document.documentElement.appendChild(notif);
|
||||
|
||||
// Gegen einen festen Ablaufzeitpunkt rechnen, damit die Anzeige auch nach
|
||||
// gedrosselten oder ausgefallenen Timer-Ticks stimmt.
|
||||
const deadline = Date.now() + (Number(remaining) > 0 ? Number(remaining) : TOTP_PERIOD) * 1000;
|
||||
let secs = remaining;
|
||||
const iv = setInterval(() => {
|
||||
const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||
secs--;
|
||||
const t = document.getElementById('__vault_totp_t');
|
||||
const bar = document.getElementById('__vault_totp_bar');
|
||||
if (secs === 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; }
|
||||
if (secs <= 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; }
|
||||
t.textContent = secs + 's';
|
||||
if (bar) { bar.style.width = Math.min(100, secs / TOTP_PERIOD * 100) + '%'; if (secs <= 10) bar.style.background = '#dc3545'; }
|
||||
if (bar) { bar.style.width = (secs / 30 * 100) + '%'; if (secs < 10) bar.style.background = '#dc3545'; }
|
||||
}, 1000);
|
||||
notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenNIT Vault",
|
||||
"version": "2.5.0",
|
||||
"version": "2.4.1",
|
||||
"description": "OpenNIT Vault – Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.",
|
||||
"homepage_url": "https://opennit.de",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"alarms",
|
||||
"offscreen",
|
||||
"identity"
|
||||
|
|
@ -29,7 +32,6 @@
|
|||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"urlmatch.js",
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
|
|
|
|||
|
|
@ -229,13 +229,29 @@ label.control-label {
|
|||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px;"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
Mit OpenNIT anmelden
|
||||
</button>
|
||||
<button class="btn btn-default" id="btnSave">URL speichern</button>
|
||||
<button class="btn btn-default" id="btnLogout" style="display:none;">Abmelden</button>
|
||||
<span id="ssoMsg"></span>
|
||||
<span id="savedMsg"></span>
|
||||
</div>
|
||||
<span class="form-text">Die Anmeldung erfolgt wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert und lässt sich jederzeit im Vault widerrufen.</span>
|
||||
<span class="form-text">Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert.</span>
|
||||
<div id="statusMsg"></div>
|
||||
|
||||
<!-- Erweitert: manueller Token -->
|
||||
<details style="margin-top:1rem;">
|
||||
<summary style="cursor:pointer;font-size:.82rem;color:#6c757d;font-weight:600;">Erweitert: manueller Token</summary>
|
||||
<div style="margin-top:.75rem;">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="apiToken">API-Token</label>
|
||||
<input type="password" class="form-control" id="apiToken"
|
||||
placeholder="Token aus dem Vault einfügen…">
|
||||
<span class="form-text">Alternative ohne SSO (z. B. Kiosk/Headless): Vault → „Extension“ → Token generieren.</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-default btn-sm" id="btnSave">Token speichern</button>
|
||||
<button class="btn btn-default btn-sm" id="btnTest">Verbindung testen</button>
|
||||
<span id="savedMsg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -259,10 +275,6 @@ label.control-label {
|
|||
<label class="control-label"><input type="checkbox" id="clipClear" checked> Zwischenablage nach 30 Sekunden leeren</label>
|
||||
<span class="form-text">Kopierte Passwörter und 2FA-Codes werden nach kurzer Zeit automatisch aus der Zwischenablage entfernt.</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label"><input type="checkbox" id="totpAutoCopy" checked> 2FA-Code beim Anmelden bereitlegen</label>
|
||||
<span class="form-text">Nach dem Ausfüllen eines Eintrags mit 2FA wird beim nächsten 2FA-Feld – auch auf einer Folgeseite – automatisch ein frischer Code in die Zwischenablage gelegt. Einfügen genügt dann mit <strong>Strg + V</strong>.</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button>
|
||||
<span id="savedSecMsg"></span>
|
||||
|
|
@ -277,9 +289,9 @@ label.control-label {
|
|||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="steps-list">
|
||||
<li><span class="step-badge">1</span><span>Tragen Sie oben die <strong>Server-URL</strong> Ihrer OpenNIT-Instanz ein (nur <strong>https://</strong>).</span></li>
|
||||
<li><span class="step-badge">2</span><span>Klicken Sie auf <strong>„Mit OpenNIT anmelden“</strong> und melden Sie sich wie gewohnt an.</span></li>
|
||||
<li><span class="step-badge">3</span><span>Bestätigen Sie den Zugriff auf den <strong>Passwort-Vault</strong> – die Erweiterung ist damit verbunden.</span></li>
|
||||
<li><span class="step-badge">1</span><span>Öffnen Sie den <strong>Passwort-Vault</strong> in Ihrer OpenNIT-Anwendung.</span></li>
|
||||
<li><span class="step-badge">2</span><span>Klicken Sie auf <strong>„Extension“</strong> in der Toolbar und generieren Sie einen API-Token.</span></li>
|
||||
<li><span class="step-badge">3</span><span>Fügen Sie den Token oben in das Token-Feld ein und klicken Sie auf <strong>Speichern</strong>.</span></li>
|
||||
<li><span class="step-badge">4</span><span>Fokussieren Sie ein Login-Feld auf einer Website – Vault-Vorschläge erscheinen automatisch.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,22 +3,21 @@
|
|||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
// Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert)
|
||||
chrome.storage.local.get(['serverUrl'], cfg => {
|
||||
chrome.storage.local.get(['serverUrl', 'apiToken'], cfg => {
|
||||
if (cfg.serverUrl) $('serverUrl').value = cfg.serverUrl;
|
||||
if (cfg.apiToken) $('apiToken').value = '••••••••';
|
||||
});
|
||||
|
||||
// Sicherheits-Einstellungen laden
|
||||
chrome.storage.local.get(['lockDuration', 'clipClear', 'totpAutoCopy'], cfg => {
|
||||
$('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15';
|
||||
$('clipClear').checked = cfg.clipClear !== false; // Standard: an
|
||||
$('totpAutoCopy').checked = cfg.totpAutoCopy !== false; // Standard: an
|
||||
chrome.storage.local.get(['lockDuration', 'clipClear'], cfg => {
|
||||
$('lockDuration').value = (cfg.lockDuration && cfg.lockDuration !== 'off') ? cfg.lockDuration : '15';
|
||||
$('clipClear').checked = cfg.clipClear !== false; // Standard: an
|
||||
});
|
||||
|
||||
$('btnSaveSec').addEventListener('click', () => {
|
||||
chrome.storage.local.set({
|
||||
lockDuration: $('lockDuration').value,
|
||||
clipClear: $('clipClear').checked,
|
||||
totpAutoCopy: $('totpAutoCopy').checked,
|
||||
}, () => {
|
||||
chrome.runtime.sendMessage({ type: 'LOCK_NOW' });
|
||||
$('savedSecMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
||||
|
|
@ -26,8 +25,27 @@ $('btnSaveSec').addEventListener('click', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// HTTPS erzwingen (außer localhost) – sonst gingen Zugangsdaten und Passwörter
|
||||
// im Klartext über die Leitung.
|
||||
// App-Name und Verbindungsstatus laden (falls Token bereits gesetzt)
|
||||
chrome.storage.local.get(['serverUrl', 'apiToken'], async cfg => {
|
||||
if (!cfg.serverUrl || !cfg.apiToken) return;
|
||||
try {
|
||||
const res = await fetch(`${cfg.serverUrl}/api/vault/extension/status`, {
|
||||
headers: { 'Authorization': `Bearer ${cfg.apiToken}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
// Name der Erweiterung bleibt fest „OpenNIT Vault"; die Instanz wird
|
||||
// beim angemeldeten Nutzer zur Orientierung angezeigt.
|
||||
if (data.user) {
|
||||
$('headerUser').textContent = data.app_name ? (data.user + ' · ' + data.app_name) : data.user;
|
||||
$('headerStatus').style.display = '';
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// HTTPS erzwingen (außer localhost) – sonst gingen Token und Passwörter im
|
||||
// Klartext über die Leitung.
|
||||
function isSecureServerUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
|
|
@ -38,20 +56,58 @@ function isSecureServerUrl(url) {
|
|||
}
|
||||
|
||||
$('btnSave').addEventListener('click', () => {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const token = $('apiToken').value.trim();
|
||||
if (!url) { showStatus('Server-URL darf nicht leer sein.', false); return; }
|
||||
if (!isSecureServerUrl(url)) {
|
||||
showStatus('Bitte eine <strong>https://</strong>-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Zugangsdaten und Passwörter unverschlüsselt übertragen.', false);
|
||||
showStatus('Bitte eine <strong>https://</strong>-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Token und Passwörter unverschlüsselt übertragen.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.storage.local.set({ serverUrl: url }, () => {
|
||||
const data = { serverUrl: url };
|
||||
if (token && !token.startsWith('•')) data.apiToken = token;
|
||||
|
||||
chrome.storage.local.set(data, () => {
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
$('savedMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
||||
setTimeout(() => { $('savedMsg').textContent = ''; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
$('btnTest').addEventListener('click', async () => {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const token = $('apiToken').value.trim();
|
||||
|
||||
if (!url || !token || token.startsWith('•')) {
|
||||
showStatus('Bitte zuerst URL und Token eingeben und speichern.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
$('btnTest').innerHTML = '<span class="fa-spin-sm"></span> Teste…';
|
||||
$('btnTest').disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/api/vault/extension/status`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
showStatus(`Verbunden als <strong>${esc(data.user)}</strong>`, true);
|
||||
if (data.user) {
|
||||
$('headerUser').textContent = data.user;
|
||||
$('headerStatus').style.display = '';
|
||||
}
|
||||
} else {
|
||||
showStatus('Ungültiger Token oder Server-Fehler.', false);
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Server nicht erreichbar: ' + esc(e.message), false);
|
||||
}
|
||||
|
||||
$('btnTest').innerHTML = 'Verbindung testen';
|
||||
$('btnTest').disabled = false;
|
||||
});
|
||||
|
||||
// ── SSO-Anmeldung (OAuth 2.0 + PKCE via chrome.identity) ────────────────────
|
||||
function b64url(bytes) {
|
||||
let s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
|
||||
|
|
@ -112,6 +168,7 @@ async function loginWithSso() {
|
|||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.access_token) { ssoMsg('Token konnte nicht ausgestellt werden.', false); return; }
|
||||
await ssoSet('local', { serverUrl: url, apiRefreshToken: data.refresh_token, apiRefreshExpiresAt: Date.now() + (data.refresh_expires_in || 0) * 1000 });
|
||||
await ssoRemove('local', ['apiToken']);
|
||||
await ssoSet('session', { accessToken: data.access_token, accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000 });
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
ssoMsg('Angemeldet.', true);
|
||||
|
|
@ -146,7 +203,7 @@ function reflectAuthState() {
|
|||
});
|
||||
}
|
||||
|
||||
// Verbindungsstatus über den Background (nutzt den SSO-Access-Token)
|
||||
// Verbindungsstatus über den Background (nutzt SSO-Access-Token oder manuellen Token)
|
||||
function loadConnStatus() {
|
||||
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => {
|
||||
if (data && data.ok) {
|
||||
|
|
|
|||
|
|
@ -147,14 +147,6 @@ body {
|
|||
.ne-pass-row .panel-input { flex: 1; }
|
||||
.ne-icon-btn { flex-shrink: 0; width: 36px; height: 36px; border: 1.5px solid var(--line); background: var(--card); border-radius: 9px; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s, border-color .12s; }
|
||||
.ne-icon-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.gen-opts { display: flex; align-items: center; gap: 14px; margin: -2px 0 2px; font-size: 11px; color: var(--muted); }
|
||||
.gen-opt { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
|
||||
.gen-select { border: 1.5px solid var(--line); border-radius: 7px; padding: 2px 4px; font-size: 11px; font-family: inherit; background: var(--card); color: var(--ink); cursor: pointer; }
|
||||
.detail-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.detail-action { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--card); border: 1.5px solid var(--line); border-radius: 9px; padding: 8px 10px; font-size: 11.5px; font-weight: 600; font-family: inherit; color: #5b6478; cursor: pointer; transition: background .12s, color .12s, border-color .12s; }
|
||||
.detail-action:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.detail-action.danger:hover { background: #dc3545; border-color: #dc3545; }
|
||||
.detail-action:disabled { opacity: .55; cursor: default; }
|
||||
.field-notes { font-size: 12px; color: var(--ink); white-space: pre-wrap; word-break: break-word; max-height: 90px; overflow-y: auto; margin-top: 2px; }
|
||||
|
||||
/* ── Lock Screen ────────────────────────────────────────── */
|
||||
|
|
@ -212,7 +204,7 @@ body {
|
|||
<!-- New Entry Panel (initially hidden) -->
|
||||
<div id="newEntryPanel">
|
||||
<div class="panel-hd">
|
||||
<span class="panel-hd-title" id="panelTitle">Neuen Eintrag anlegen</span>
|
||||
<span class="panel-hd-title">Neuen Eintrag anlegen</span>
|
||||
<button class="panel-close" id="btnCloseNew" title="Schließen">✕</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
|
@ -227,19 +219,6 @@ body {
|
|||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="gen-opts">
|
||||
<label class="gen-opt" for="genLen">Länge
|
||||
<select class="gen-select" id="genLen">
|
||||
<option>12</option>
|
||||
<option>16</option>
|
||||
<option selected>20</option>
|
||||
<option>24</option>
|
||||
<option>32</option>
|
||||
<option>48</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="gen-opt"><input type="checkbox" id="genSymbols" checked> Sonderzeichen</label>
|
||||
</div>
|
||||
<input class="panel-input" id="neUrl" type="url" placeholder="URL (https://…)" autocomplete="off">
|
||||
<textarea class="panel-input" id="neNotes" placeholder="Notizen" rows="2" style="resize:vertical;font-family:inherit;"></textarea>
|
||||
<button class="panel-save" id="btnSaveNew">Speichern</button>
|
||||
|
|
@ -269,6 +248,9 @@ body {
|
|||
<div class="field-lbl">Benutzername</div>
|
||||
<div class="field-row">
|
||||
<span class="field-val" id="detailUser"></span>
|
||||
<button class="field-btn" id="btnRevealUser" title="Anzeigen/Verbergen">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="field-btn" id="btnCopyUser" title="Kopieren">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
|
|
@ -316,16 +298,6 @@ body {
|
|||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
Auf dieser Seite ausfüllen
|
||||
</button>
|
||||
<div class="detail-actions">
|
||||
<button class="detail-action" id="btnDetailEdit">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button class="detail-action danger" id="btnDetailDelete">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -370,7 +342,6 @@ body {
|
|||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="urlmatch.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,7 +5,6 @@ let pageMatches = [];
|
|||
let entryIndex = {}; // id -> entry
|
||||
let detailState = null; // aktiver Eintrag im Detail-Panel
|
||||
let selIndex = -1; // Tastatur-Auswahl in der Liste
|
||||
let editingId = null; // gesetzt, solange das Panel einen bestehenden Eintrag bearbeitet
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
|
|
@ -29,14 +28,13 @@ async function init() {
|
|||
// Detail-Panel
|
||||
$('btnDetailBack').addEventListener('click', closeDetail);
|
||||
$('btnDetailClose').addEventListener('click', closeDetail);
|
||||
$('btnRevealUser').addEventListener('click', toggleRevealUser);
|
||||
$('btnCopyUser').addEventListener('click', () => copySecret($('detailUser').dataset.value || '', 'Benutzername kopiert'));
|
||||
$('btnRevealPass').addEventListener('click', toggleRevealPass);
|
||||
$('btnCopyPass').addEventListener('click', copyDetailPassword);
|
||||
$('btnCopyTotp').addEventListener('click', () => { const c = $('detailTotp').dataset.code || ''; if (c) copySecret(c, 'TOTP kopiert'); });
|
||||
$('btnCopyNotes').addEventListener('click', () => copySecret($('detailNotes').dataset.value || '', 'Notiz kopiert'));
|
||||
$('btnCopyNotes').addEventListener('click', () => copyToClipboard($('detailNotes').dataset.value || '', 'Notiz kopiert'));
|
||||
$('btnDetailFill').addEventListener('click', fillActiveTab);
|
||||
$('btnDetailEdit').addEventListener('click', openEditPanel);
|
||||
$('btnDetailDelete').addEventListener('click', deleteCurrentEntry);
|
||||
|
||||
// Lock-Screen
|
||||
$('lockSubmit').addEventListener('click', submitPin);
|
||||
|
|
@ -213,9 +211,6 @@ function setSel(items, idx) {
|
|||
|
||||
// ── New Entry ─────────────────────────────────────────────────────────────
|
||||
function openNewPanel() {
|
||||
editingId = null;
|
||||
$('panelTitle').textContent = 'Neuen Eintrag anlegen';
|
||||
$('nePassword').placeholder = 'Passwort';
|
||||
$('listWrap').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
$('detailPanel').style.display = 'none';
|
||||
|
|
@ -237,126 +232,74 @@ function openNewPanel() {
|
|||
}
|
||||
|
||||
function closeNewPanel() {
|
||||
editingId = null;
|
||||
$('newEntryPanel').style.display = 'none';
|
||||
$('listWrap').style.display = '';
|
||||
$('search').closest('.search-wrap').style.display = '';
|
||||
}
|
||||
|
||||
// Gleichverteilte Zufallszahl aus [0, max) – verwirft die Werte des obersten,
|
||||
// unvollständigen Blocks, damit kein Rest-Modulo einzelne Zeichen bevorzugt.
|
||||
function randomBelow(max) {
|
||||
const limit = Math.floor(0x100000000 / max) * max;
|
||||
const buf = new Uint32Array(1);
|
||||
do { crypto.getRandomValues(buf); } while (buf[0] >= limit);
|
||||
return buf[0] % max;
|
||||
}
|
||||
|
||||
/** Zeichensätze ohne optisch verwechselbare Zeichen (l/I/1, O/0). */
|
||||
const GEN_SETS = [
|
||||
'abcdefghijkmnopqrstuvwxyz',
|
||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
'23456789',
|
||||
];
|
||||
const GEN_SYMBOLS = '!@#$%^&*()-_=+[]{}';
|
||||
|
||||
function generatePassword() {
|
||||
const len = Math.max(8, parseInt($('genLen').value, 10) || 20);
|
||||
const sets = $('genSymbols').checked ? GEN_SETS.concat(GEN_SYMBOLS) : GEN_SETS.slice();
|
||||
const all = sets.join('');
|
||||
|
||||
// Je Satz ein Zeichen garantieren, den Rest frei ziehen …
|
||||
const out = sets.map(s => s[randomBelow(s.length)]);
|
||||
while (out.length < len) out.push(all[randomBelow(all.length)]);
|
||||
|
||||
// … und danach mischen, damit die garantierten Zeichen nicht vorne stehen.
|
||||
// Der Zufall dafür wird frisch gezogen und nicht aus der Zeichenwahl wiederverwendet.
|
||||
const len = 20;
|
||||
const sets = [
|
||||
'abcdefghijkmnopqrstuvwxyz',
|
||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
'23456789',
|
||||
'!@#$%^&*()-_=+[]{}',
|
||||
];
|
||||
const all = sets.join('');
|
||||
const buf = new Uint32Array(len);
|
||||
crypto.getRandomValues(buf);
|
||||
let out = [];
|
||||
// Mindestens ein Zeichen je Set
|
||||
sets.forEach((s, i) => { out.push(s[buf[i] % s.length]); });
|
||||
for (let i = sets.length; i < len; i++) out.push(all[buf[i] % all.length]);
|
||||
// mischen
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = randomBelow(i + 1);
|
||||
const j = buf[i] % (i + 1);
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
|
||||
$('nePassword').value = out.join('');
|
||||
$('nePassword').type = 'text';
|
||||
}
|
||||
|
||||
// Bestehenden Eintrag im selben Panel bearbeiten. Das Passwortfeld bleibt leer –
|
||||
// leer bedeutet serverseitig „unverändert", sodass das Passwort das Popup nie verlässt.
|
||||
function openEditPanel() {
|
||||
if (!detailState) return;
|
||||
const e = entryIndex[detailState.id];
|
||||
if (!e) return;
|
||||
|
||||
editingId = detailState.id;
|
||||
closeDetail();
|
||||
|
||||
$('panelTitle').textContent = 'Eintrag bearbeiten';
|
||||
$('neTitle').value = e.title || '';
|
||||
$('neUsername').value = e.username || '';
|
||||
$('nePassword').value = '';
|
||||
$('nePassword').type = 'password';
|
||||
$('nePassword').placeholder = 'Passwort (leer = unverändert)';
|
||||
$('neUrl').value = e.url || '';
|
||||
$('neNotes').value = e.notes || '';
|
||||
$('newEntryMsg').textContent = '';
|
||||
|
||||
$('listWrap').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
$('detailPanel').style.display = 'none';
|
||||
$('newEntryPanel').style.display = 'block';
|
||||
$('neTitle').focus();
|
||||
}
|
||||
|
||||
function deleteCurrentEntry() {
|
||||
if (!detailState) return;
|
||||
const e = entryIndex[detailState.id];
|
||||
if (!e) return;
|
||||
if (!window.confirm('Eintrag „' + (e.title || '') + '" wirklich löschen?')) return;
|
||||
|
||||
const btn = $('btnDetailDelete');
|
||||
btn.disabled = true;
|
||||
chrome.runtime.sendMessage({ type: 'DELETE_ENTRY', id: detailState.id }, resp => {
|
||||
btn.disabled = false;
|
||||
if (resp?.ok) { closeDetail(); reload(true); showToast('Eintrag gelöscht'); return; }
|
||||
if (resp?.locked) { showLockScreen(); return; }
|
||||
showToast(resp?.error || 'Löschen fehlgeschlagen');
|
||||
});
|
||||
}
|
||||
|
||||
// Speichern läuft – wie alle anderen Aufrufe – über den Background-Service-Worker,
|
||||
// der den gültigen Zugang beisteuert.
|
||||
function saveNewEntry() {
|
||||
async function saveNewEntry() {
|
||||
const title = $('neTitle').value.trim();
|
||||
if (!title) { $('newEntryMsg').textContent = 'Titel ist erforderlich.'; return; }
|
||||
|
||||
const cfg = await new Promise(r => chrome.storage.local.get(['serverUrl', 'apiToken'], r));
|
||||
if (!cfg.serverUrl || !cfg.apiToken) { $('newEntryMsg').textContent = 'Nicht konfiguriert.'; return; }
|
||||
|
||||
$('btnSaveNew').disabled = true;
|
||||
$('btnSaveNew').textContent = '...';
|
||||
$('newEntryMsg').textContent = '';
|
||||
|
||||
const entry = {
|
||||
title: title,
|
||||
username: $('neUsername').value.trim(),
|
||||
password: $('nePassword').value,
|
||||
url: $('neUrl').value.trim(),
|
||||
notes: $('neNotes').value.trim(),
|
||||
};
|
||||
const msg = editingId
|
||||
? { type: 'UPDATE_ENTRY', id: editingId, entry }
|
||||
: { type: 'CREATE_ENTRY', entry };
|
||||
const wasEditing = !!editingId;
|
||||
const fd = new FormData();
|
||||
fd.append('title', title);
|
||||
fd.append('username', $('neUsername').value.trim());
|
||||
fd.append('password', $('nePassword').value);
|
||||
fd.append('url', $('neUrl').value.trim());
|
||||
fd.append('notes', $('neNotes').value.trim());
|
||||
|
||||
chrome.runtime.sendMessage(msg, resp => {
|
||||
$('btnSaveNew').disabled = false;
|
||||
$('btnSaveNew').textContent = 'Speichern';
|
||||
if (resp?.ok) {
|
||||
try {
|
||||
const res = await fetch(cfg.serverUrl + '/api/vault/extension/entries', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + cfg.apiToken },
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
closeNewPanel();
|
||||
reload(true);
|
||||
showToast(wasEditing ? 'Eintrag aktualisiert' : 'Eintrag gespeichert');
|
||||
return;
|
||||
showToast('Eintrag gespeichert');
|
||||
} else {
|
||||
$('newEntryMsg').textContent = data.error || 'Fehler beim Speichern.';
|
||||
}
|
||||
if (resp?.locked) { showLockScreen(); return; }
|
||||
$('newEntryMsg').textContent = resp?.error || 'Fehler beim Speichern.';
|
||||
});
|
||||
} catch (e) {
|
||||
$('newEntryMsg').textContent = 'Verbindungsfehler: ' + e.message;
|
||||
}
|
||||
|
||||
$('btnSaveNew').disabled = false;
|
||||
$('btnSaveNew').textContent = 'Speichern';
|
||||
}
|
||||
|
||||
// ── Liste (Klick öffnet Detailansicht) ─────────────────────────────────────
|
||||
|
|
@ -421,7 +364,7 @@ function openDetail(id) {
|
|||
const e = entryIndex[String(id)];
|
||||
if (!e) return;
|
||||
closeDetailTimers();
|
||||
detailState = { id: String(id), password: null, revealPass: false, totpInterval: null };
|
||||
detailState = { id: String(id), password: null, revealUser: true, revealPass: false, totpInterval: null };
|
||||
|
||||
$('listWrap').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
|
|
@ -456,18 +399,21 @@ function openDetail(id) {
|
|||
urlLink.style.display = 'none';
|
||||
}
|
||||
|
||||
// Benutzername – kein Geheimnis, daher immer im Klartext.
|
||||
// Benutzername (standardmäßig sichtbar; Auge schaltet Maskierung)
|
||||
const uval = e.username || '';
|
||||
const uEl = $('detailUser');
|
||||
uEl.dataset.value = uval;
|
||||
detailState.revealUser = true;
|
||||
if (uval) {
|
||||
uEl.classList.remove('empty');
|
||||
uEl.textContent = uval;
|
||||
$('btnCopyUser').style.display = '';
|
||||
$('btnRevealUser').style.display = '';
|
||||
$('btnCopyUser').style.display = '';
|
||||
} else {
|
||||
uEl.classList.add('empty');
|
||||
uEl.textContent = 'Kein Benutzername';
|
||||
$('btnCopyUser').style.display = 'none';
|
||||
$('btnRevealUser').style.display = 'none';
|
||||
$('btnCopyUser').style.display = 'none';
|
||||
}
|
||||
|
||||
// Passwort (standardmäßig maskiert)
|
||||
|
|
@ -501,6 +447,14 @@ function closeDetail() {
|
|||
$('search').closest('.search-wrap').style.display = '';
|
||||
}
|
||||
|
||||
function toggleRevealUser() {
|
||||
const uEl = $('detailUser');
|
||||
const val = uEl.dataset.value || '';
|
||||
if (!val) return;
|
||||
detailState.revealUser = !detailState.revealUser;
|
||||
uEl.textContent = detailState.revealUser ? val : '•'.repeat(Math.min(val.length, 14));
|
||||
}
|
||||
|
||||
async function ensurePassword(id) {
|
||||
if (detailState && detailState.password !== null) return detailState.password;
|
||||
const pw = await new Promise(resolve => {
|
||||
|
|
@ -530,12 +484,6 @@ async function copyDetailPassword() {
|
|||
else showToast('Kein Passwort');
|
||||
}
|
||||
|
||||
const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert)
|
||||
|
||||
// Restlaufzeit des 2FA-Codes. Die Anzeige rechnet gegen einen festen Ablaufzeitpunkt
|
||||
// statt blind herunterzuzählen – so bleibt sie korrekt, wenn der Timer gedrosselt wird
|
||||
// oder ein Tick ausfällt. Nachgeladen wird erst, wenn der Code abgelaufen ist, und pro
|
||||
// Ablauf nur einmal.
|
||||
function loadDetailTotp(id) {
|
||||
const codeEl = $('detailTotp');
|
||||
const secsEl = $('detailTotpSecs');
|
||||
|
|
@ -544,55 +492,33 @@ function loadDetailTotp(id) {
|
|||
codeEl.dataset.code = '';
|
||||
secsEl.textContent = '';
|
||||
|
||||
let deadline = 0;
|
||||
let refetching = false;
|
||||
|
||||
const stillOpen = () => detailState && detailState.id === String(id);
|
||||
|
||||
const render = () => {
|
||||
const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||
secsEl.textContent = secs + 's';
|
||||
if (barEl) {
|
||||
barEl.style.width = Math.min(100, Math.round(secs / TOTP_PERIOD * 100)) + '%';
|
||||
barEl.style.background = secs <= 10 ? '#dc3545' : '#34d399';
|
||||
}
|
||||
return secs;
|
||||
};
|
||||
|
||||
const apply = (code, remaining) => {
|
||||
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
||||
codeEl.dataset.code = code;
|
||||
// Der Server liefert die Restsekunden des laufenden Zeitfensters; daraus wird
|
||||
// ein Ablaufzeitpunkt, gegen den die Anzeige lokal rechnet.
|
||||
deadline = Date.now() + (Number(remaining) > 0 ? Number(remaining) : TOTP_PERIOD) * 1000;
|
||||
render();
|
||||
};
|
||||
|
||||
const refetch = () => {
|
||||
if (refetching) return;
|
||||
refetching = true;
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r => {
|
||||
refetching = false;
|
||||
if (!stillOpen()) return;
|
||||
if (r?.code) { apply(r.code, r.remaining); return; }
|
||||
// Kein Code mehr (gesperrt oder Verbindung weg) – Anzeige zurücksetzen
|
||||
// statt weiter auf einem abgelaufenen Wert stehen zu bleiben.
|
||||
closeDetailTimers();
|
||||
codeEl.textContent = '—';
|
||||
codeEl.dataset.code = '';
|
||||
secsEl.textContent = '';
|
||||
if (barEl) barEl.style.width = '0%';
|
||||
});
|
||||
};
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => {
|
||||
if (!stillOpen()) return;
|
||||
if (!detailState || detailState.id !== String(id)) return;
|
||||
if (!resp?.code) { codeEl.textContent = '—'; return; }
|
||||
|
||||
const apply = (code, remaining) => {
|
||||
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
||||
codeEl.dataset.code = code;
|
||||
secsEl.textContent = remaining + 's';
|
||||
if (barEl) barEl.style.width = Math.round(remaining / 30 * 100) + '%';
|
||||
};
|
||||
apply(resp.code, resp.remaining);
|
||||
|
||||
let secs = resp.remaining;
|
||||
detailState.totpInterval = setInterval(() => {
|
||||
if (!stillOpen()) return;
|
||||
if (render() === 0) refetch();
|
||||
secs--;
|
||||
if (secs <= 0) {
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r2 => {
|
||||
if (!detailState || detailState.id !== String(id)) return;
|
||||
if (r2?.code) { secs = r2.remaining; apply(r2.code, r2.remaining); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
secsEl.textContent = secs + 's';
|
||||
if (barEl) {
|
||||
barEl.style.width = Math.round(secs / 30 * 100) + '%';
|
||||
barEl.style.background = secs < 10 ? '#dc3545' : '#34d399';
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
|
@ -634,12 +560,30 @@ async function fillActiveTab() {
|
|||
}
|
||||
|
||||
// ── Helfer ────────────────────────────────────────────────────────────────
|
||||
function hostOf(u) { return VaultUrl.host(u); }
|
||||
function hostOf(u) {
|
||||
try {
|
||||
const s = String(u || '');
|
||||
return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase();
|
||||
} catch { return ''; }
|
||||
}
|
||||
// True, wenn eine der (mehrzeiligen) Eintrags-URLs zur Seiten-Domain passt –
|
||||
// oder wenn im Eintrag gar keine URL hinterlegt ist (dann keine Warnung).
|
||||
function fillDomainMatches(entryUrls, pageUrl) { return VaultUrl.matchesOrUnset(entryUrls, pageUrl); }
|
||||
// Alles, was aus einem Eintrag kommt, gilt als Geheimnis – Notizen enthalten in
|
||||
// der Praxis ebenso oft Wiederherstellungscodes wie das Passwortfeld selbst.
|
||||
function fillDomainMatches(entryUrls, pageUrl) {
|
||||
const pageHost = hostOf(pageUrl);
|
||||
const list = String(entryUrls || '').split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (!list.length) return true;
|
||||
if (!pageHost) return false;
|
||||
return list.some(u => {
|
||||
let eh = hostOf(u);
|
||||
if (eh.startsWith('*.')) eh = eh.slice(2);
|
||||
return eh && (pageHost === eh || pageHost.endsWith('.' + eh) || eh.endsWith('.' + pageHost));
|
||||
});
|
||||
}
|
||||
function copyToClipboard(text, msg) {
|
||||
navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => showToast('Fehler'));
|
||||
}
|
||||
// Wie copyToClipboard, plant aber zusätzlich das automatische Leeren der
|
||||
// Zwischenablage (für Zugangsdaten/2FA).
|
||||
function copySecret(text, msg) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(msg);
|
||||
|
|
@ -653,6 +597,6 @@ function showToast(msg) {
|
|||
setTimeout(() => t.classList.remove('show'), 1800);
|
||||
}
|
||||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, '''); }
|
||||
function escAttr(s) { return String(s||'').replace(/"/g,'"'); }
|
||||
|
||||
init();
|
||||
Loading…
Add table
Add a link
Reference in a new issue