Countdown des 2FA-Codes: Die Anzeige zaehlte blind im Sekundentakt herunter und lief dadurch aus dem Takt. Am Ende eines Zeitfensters wurde zwar ein neuer Code angefordert, der Zaehler aber nicht angehalten - bei langsamer oder fehlschlagender Antwort lief er ins Negative und stiess jede Sekunde eine weitere Anfrage an (inkl. Audit-Eintrag je Abruf), waehrend die Anzeige auf dem alten Wert stehenblieb. Die Restlaufzeit wird jetzt aus einem festen Ablaufzeitpunkt berechnet, sodass gedrosselte oder ausgefallene Ticks sie nicht verschieben. Pro Ablauf wird genau einmal nachgeladen; liefert der Server keinen Code mehr (gesperrt/offline), stoppt der Timer und die Anzeige wird geleert. Dieselbe Rechnung gilt fuer die Benachrichtigung im Seiteninhalt; die Periode steckt dort wie im Popup in einer Konstanten statt als 30 im Ausdruck. Manueller API-Token: Eingabefeld, "Token speichern" und "Verbindung testen" sind aus den Optionen entfernt; die Server-URL laesst sich weiterhin separat speichern. getAccessToken() kennt nur noch den SSO-Weg, und ein aus einer frueheren Version uebernommener Token wird bei onInstalled aus chrome.storage.local geloescht. Doku und Store-Listing entsprechend angepasst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
621 lines
No EOL
24 KiB
JavaScript
621 lines
No EOL
24 KiB
JavaScript
'use strict';
|
||
|
||
let allEntries = null;
|
||
let pageMatches = [];
|
||
let entryIndex = {}; // id -> entry
|
||
let detailState = null; // aktiver Eintrag im Detail-Panel
|
||
let selIndex = -1; // Tastatur-Auswahl in der Liste
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
async function init() {
|
||
chrome.storage.local.get(['serverUrl'], cfg => {
|
||
if (cfg.serverUrl) $('btnOpen').href = cfg.serverUrl + '/vault';
|
||
});
|
||
|
||
$('search').addEventListener('input', onSearch);
|
||
$('search').addEventListener('keydown', onListKeydown);
|
||
$('btnOptions').addEventListener('click', () => chrome.runtime.openOptionsPage());
|
||
$('btnNew').addEventListener('click', openNewPanel);
|
||
$('btnCloseNew').addEventListener('click', closeNewPanel);
|
||
$('btnSaveNew').addEventListener('click', saveNewEntry);
|
||
$('btnGenPw').addEventListener('click', generatePassword);
|
||
$('btnRevealNewPw').addEventListener('click', () => {
|
||
const f = $('nePassword');
|
||
f.type = f.type === 'password' ? 'text' : 'password';
|
||
});
|
||
|
||
// 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', () => copyToClipboard($('detailNotes').dataset.value || '', 'Notiz kopiert'));
|
||
$('btnDetailFill').addEventListener('click', fillActiveTab);
|
||
|
||
// Lock-Screen
|
||
$('lockSubmit').addEventListener('click', submitPin);
|
||
$('lockPin').addEventListener('keydown', e => { if (e.key === 'Enter') submitPin(); });
|
||
$('btnLock').addEventListener('click', lockNow);
|
||
|
||
boot();
|
||
}
|
||
|
||
// Reihenfolge: Status (App/User/PIN) → Lock prüfen → Liste oder PIN-Schirm.
|
||
function boot() {
|
||
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, resp => {
|
||
if (resp?.ok) {
|
||
$('hdTitle').textContent = 'OpenNIT Vault';
|
||
// Untertitel: angemeldeter Nutzer und – zur Orientierung – die Instanz.
|
||
const parts = [];
|
||
if (resp.user) parts.push(resp.user);
|
||
if (resp.app_name) parts.push(resp.app_name);
|
||
$('hdUser').textContent = parts.join(' · ');
|
||
}
|
||
chrome.runtime.sendMessage({ type: 'GET_LOCK' }, lock => {
|
||
$('btnLock').style.display = lock?.required ? '' : 'none';
|
||
if (lock?.required && !lock.unlocked) {
|
||
showLockScreen();
|
||
} else {
|
||
hideLockScreen();
|
||
reload(true);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Lock-Screen ────────────────────────────────────────────────────────────
|
||
function showLockScreen() {
|
||
$('lockScreen').style.display = 'block';
|
||
$('listWrap').style.display = 'none';
|
||
$('newEntryPanel').style.display = 'none';
|
||
$('detailPanel').style.display = 'none';
|
||
$('search').closest('.search-wrap').style.display = 'none';
|
||
$('lockMsg').textContent = '';
|
||
$('lockPin').value = '';
|
||
setTimeout(() => $('lockPin').focus(), 50);
|
||
}
|
||
function hideLockScreen() {
|
||
$('lockScreen').style.display = 'none';
|
||
$('search').closest('.search-wrap').style.display = '';
|
||
}
|
||
function submitPin() {
|
||
const pin = $('lockPin').value;
|
||
if (!pin) { $('lockMsg').textContent = 'Bitte PIN eingeben.'; return; }
|
||
$('lockSubmit').disabled = true;
|
||
$('lockSubmit').textContent = '…';
|
||
$('lockMsg').textContent = '';
|
||
chrome.runtime.sendMessage({ type: 'DO_UNLOCK', pin }, resp => {
|
||
$('lockSubmit').disabled = false;
|
||
$('lockSubmit').textContent = 'Entsperren';
|
||
if (resp?.ok) {
|
||
hideLockScreen();
|
||
reload(true);
|
||
} else {
|
||
let m = resp?.error || 'PIN falsch.';
|
||
if (resp?.lockSecs > 0) m += ' (' + resp.lockSecs + 's gesperrt)';
|
||
$('lockMsg').textContent = m;
|
||
$('lockPin').value = '';
|
||
$('lockPin').focus();
|
||
}
|
||
});
|
||
}
|
||
function lockNow() {
|
||
chrome.runtime.sendMessage({ type: 'LOCK_NOW' }, () => showLockScreen());
|
||
}
|
||
|
||
function reload(force) {
|
||
closeDetailTimers();
|
||
detailState = null;
|
||
selIndex = -1;
|
||
$('search').value = '';
|
||
$('listWrap').innerHTML = '<div class="loading"><div class="spin"></div></div>';
|
||
$('listWrap').style.display = '';
|
||
$('newEntryPanel').style.display = 'none';
|
||
$('detailPanel').style.display = 'none';
|
||
$('search').closest('.search-wrap').style.display = '';
|
||
|
||
chrome.runtime.sendMessage({ type: 'GET_ENTRIES', force }, resp => {
|
||
// Serverseitig gesperrt (Token-Härtung) → PIN-Schirm zeigen.
|
||
if (resp?.locked) { showLockScreen(); return; }
|
||
allEntries = resp?.entries ?? null;
|
||
entryIndex = {};
|
||
if (allEntries === null) {
|
||
$('listWrap').innerHTML = '<div class="error">⚠ Nicht verbunden.<br>Einstellungen prüfen.</div>';
|
||
return;
|
||
}
|
||
indexEntries(allEntries);
|
||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||
const url = tabs[0]?.url;
|
||
if (url && !url.startsWith('chrome://') && !url.startsWith('chrome-extension://')) {
|
||
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url }, r2 => {
|
||
pageMatches = r2?.entries || [];
|
||
indexEntries(pageMatches);
|
||
renderDefault();
|
||
});
|
||
} else {
|
||
pageMatches = [];
|
||
renderDefault();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function indexEntries(list) {
|
||
(list || []).forEach(e => { entryIndex[String(e.id)] = e; });
|
||
}
|
||
|
||
function renderDefault() {
|
||
selIndex = -1;
|
||
if (pageMatches.length > 0) {
|
||
$('listWrap').innerHTML =
|
||
'<div class="section-lbl match">Passend für diese Seite</div>' +
|
||
'<div class="entries" id="eList">' + pageMatches.map(e => entryHtml(e)).join('') + '</div>';
|
||
} else {
|
||
$('listWrap').innerHTML =
|
||
'<div class="section-lbl">Alle Einträge (' + allEntries.length + ')</div>' +
|
||
'<div class="entries scrollable" id="eList">' +
|
||
(allEntries.length ? allEntries.map(e => entryHtml(e)).join('') : '<div class="empty">Noch keine Einträge vorhanden.</div>') +
|
||
'</div>';
|
||
}
|
||
const el = document.getElementById('eList');
|
||
if (el) attachHandlers(el);
|
||
}
|
||
|
||
function onSearch() {
|
||
selIndex = -1;
|
||
const q = ($('search').value || '').trim().toLowerCase();
|
||
if (!q) { renderDefault(); return; }
|
||
if (!allEntries) return;
|
||
|
||
const filtered = allEntries.filter(e =>
|
||
(e.title ||'').toLowerCase().includes(q) ||
|
||
(e.username ||'').toLowerCase().includes(q) ||
|
||
(e.url ||'').toLowerCase().includes(q) ||
|
||
(e.notes ||'').toLowerCase().includes(q) ||
|
||
(e.team_name||'').toLowerCase().includes(q)
|
||
);
|
||
|
||
$('listWrap').innerHTML =
|
||
'<div class="section-lbl">Suche (' + filtered.length + ')</div>' +
|
||
'<div class="entries scrollable" id="eList">' +
|
||
(filtered.length ? filtered.map(e => entryHtml(e)).join('') : '<div class="empty">Keine Einträge gefunden.</div>') +
|
||
'</div>';
|
||
|
||
const el = document.getElementById('eList');
|
||
if (el) attachHandlers(el);
|
||
}
|
||
|
||
// Tastatur-Navigation aus dem Suchfeld heraus (↑↓ wählt, Enter öffnet).
|
||
function onListKeydown(e) {
|
||
if (detailState || $('newEntryPanel').style.display === 'block') return;
|
||
const items = [...document.querySelectorAll('#eList .entry')];
|
||
if (!items.length) return;
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(items, selIndex + 1); }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(items, selIndex - 1); }
|
||
else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const target = selIndex >= 0 ? items[selIndex] : items[0];
|
||
if (target) openDetail(target.dataset.id);
|
||
}
|
||
}
|
||
function setSel(items, idx) {
|
||
items.forEach(i => i.classList.remove('kbd-sel'));
|
||
selIndex = Math.max(0, Math.min(idx, items.length - 1));
|
||
const el = items[selIndex];
|
||
if (el) { el.classList.add('kbd-sel'); el.scrollIntoView({ block: 'nearest' }); }
|
||
}
|
||
|
||
// ── New Entry ─────────────────────────────────────────────────────────────
|
||
function openNewPanel() {
|
||
$('listWrap').style.display = 'none';
|
||
$('search').closest('.search-wrap').style.display = 'none';
|
||
$('detailPanel').style.display = 'none';
|
||
$('newEntryPanel').style.display = 'block';
|
||
$('neTitle').value = '';
|
||
$('neUsername').value = '';
|
||
$('nePassword').value = '';
|
||
$('nePassword').type = 'password';
|
||
$('neUrl').value = '';
|
||
$('neNotes').value = '';
|
||
$('newEntryMsg').textContent = '';
|
||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||
const url = tabs[0]?.url;
|
||
if (url && !url.startsWith('chrome://') && !url.startsWith('chrome-extension://')) {
|
||
$('neUrl').value = url;
|
||
}
|
||
});
|
||
$('neTitle').focus();
|
||
}
|
||
|
||
function closeNewPanel() {
|
||
$('newEntryPanel').style.display = 'none';
|
||
$('listWrap').style.display = '';
|
||
$('search').closest('.search-wrap').style.display = '';
|
||
}
|
||
|
||
function generatePassword() {
|
||
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 = buf[i] % (i + 1);
|
||
[out[i], out[j]] = [out[j], out[i]];
|
||
}
|
||
$('nePassword').value = out.join('');
|
||
$('nePassword').type = 'text';
|
||
}
|
||
|
||
// Speichern läuft – wie alle anderen Aufrufe – über den Background-Service-Worker,
|
||
// der den gültigen Zugang beisteuert.
|
||
function saveNewEntry() {
|
||
const title = $('neTitle').value.trim();
|
||
if (!title) { $('newEntryMsg').textContent = 'Titel ist erforderlich.'; 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(),
|
||
};
|
||
|
||
chrome.runtime.sendMessage({ type: 'CREATE_ENTRY', entry }, resp => {
|
||
$('btnSaveNew').disabled = false;
|
||
$('btnSaveNew').textContent = 'Speichern';
|
||
if (resp?.ok) {
|
||
closeNewPanel();
|
||
reload(true);
|
||
showToast('Eintrag gespeichert');
|
||
return;
|
||
}
|
||
if (resp?.locked) { showLockScreen(); return; }
|
||
$('newEntryMsg').textContent = resp?.error || 'Fehler beim Speichern.';
|
||
});
|
||
}
|
||
|
||
// ── Liste (Klick öffnet Detailansicht) ─────────────────────────────────────
|
||
function monogram(title) {
|
||
const s = String(title || '?');
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
|
||
const ch = s.charAt(0).toUpperCase().replace(/[&<>]/g, '');
|
||
return { hue: h, ch: ch };
|
||
}
|
||
|
||
function entryHtml(e) {
|
||
const userText = esc(e.username) || '<span style="color:#adb5bd;font-style:italic">Kein Benutzername</span>';
|
||
const m = monogram(e.title);
|
||
const icon = `<span class="entry-mono" style="display:inline-flex;width:20px;height:20px;border-radius:4px;align-items:center;justify-content:center;font-size:11px;font-weight:700;background:hsl(${m.hue},52%,90%);color:hsl(${m.hue},55%,38%);">${m.ch}</span>`;
|
||
const totpBadge = e.has_totp ? '<span class="entry-2fa">2FA</span>' : '';
|
||
return `
|
||
<div class="entry" data-id="${e.id}" data-domain="${escAttr(e.favicon_domain)}">
|
||
<div class="entry-icon">${icon}</div>
|
||
<div class="entry-info">
|
||
<div class="entry-title">${esc(e.title)}${totpBadge}</div>
|
||
<div class="entry-user">${userText}</div>
|
||
<div class="entry-meta">
|
||
<div class="entry-url">${esc(e.url) || ''}</div>
|
||
${e.team_name ? `<span class="team-badge">${esc(e.team_name)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<svg class="entry-chev" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function attachHandlers(container) {
|
||
container.querySelectorAll('.entry').forEach(row => {
|
||
row.addEventListener('click', () => openDetail(row.dataset.id));
|
||
});
|
||
loadFavicons(container);
|
||
}
|
||
|
||
function loadFavicons(container) {
|
||
container.querySelectorAll('.entry[data-domain]').forEach(row => {
|
||
if (!row.dataset.domain) return;
|
||
const id = row.dataset.id;
|
||
chrome.runtime.sendMessage({ type: 'GET_FAVICON', id }, resp => {
|
||
if (resp?.dataUrl) {
|
||
const ic = row.querySelector('.entry-icon');
|
||
if (ic) ic.innerHTML = '<img src="' + resp.dataUrl + '" alt="">';
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Detailansicht ─────────────────────────────────────────────────────────
|
||
function closeDetailTimers() {
|
||
if (detailState && detailState.totpInterval) {
|
||
clearInterval(detailState.totpInterval);
|
||
detailState.totpInterval = null;
|
||
}
|
||
}
|
||
|
||
function openDetail(id) {
|
||
const e = entryIndex[String(id)];
|
||
if (!e) return;
|
||
closeDetailTimers();
|
||
detailState = { id: String(id), password: null, revealUser: true, revealPass: false, totpInterval: null };
|
||
|
||
$('listWrap').style.display = 'none';
|
||
$('search').closest('.search-wrap').style.display = 'none';
|
||
$('newEntryPanel').style.display = 'none';
|
||
$('detailPanel').style.display = 'block';
|
||
|
||
$('detailHdTitle').textContent = e.title || 'Eintrag';
|
||
$('detailName').textContent = e.title || '';
|
||
|
||
// Icon: Favicon (gecacht) oder Monogramm
|
||
const icon = $('detailIcon');
|
||
const m = monogram(e.title);
|
||
icon.style.background = `hsl(${m.hue},52%,90%)`;
|
||
icon.innerHTML = `<span style="color:hsl(${m.hue},55%,38%);">${m.ch}</span>`;
|
||
if (e.favicon_domain) {
|
||
chrome.runtime.sendMessage({ type: 'GET_FAVICON', id }, resp => {
|
||
if (resp?.dataUrl && detailState && detailState.id === String(id)) {
|
||
icon.style.background = '#eef0f7';
|
||
icon.innerHTML = '<img src="' + resp.dataUrl + '" alt="">';
|
||
}
|
||
});
|
||
}
|
||
|
||
// URL
|
||
const urlLink = $('detailUrlLink');
|
||
const firstUrl = String(e.url || '').split('\n')[0].trim();
|
||
if (firstUrl) {
|
||
urlLink.textContent = firstUrl;
|
||
urlLink.href = /^https?:\/\//i.test(firstUrl) ? firstUrl : 'https://' + firstUrl;
|
||
urlLink.style.display = '';
|
||
} else {
|
||
urlLink.style.display = 'none';
|
||
}
|
||
|
||
// 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;
|
||
$('btnRevealUser').style.display = '';
|
||
$('btnCopyUser').style.display = '';
|
||
} else {
|
||
uEl.classList.add('empty');
|
||
uEl.textContent = 'Kein Benutzername';
|
||
$('btnRevealUser').style.display = 'none';
|
||
$('btnCopyUser').style.display = 'none';
|
||
}
|
||
|
||
// Passwort (standardmäßig maskiert)
|
||
detailState.revealPass = false;
|
||
$('detailPass').textContent = '••••••••••';
|
||
|
||
// Notizen
|
||
const notes = (e.notes || '').trim();
|
||
if (notes) {
|
||
$('detailNotes').textContent = notes;
|
||
$('detailNotes').dataset.value = notes;
|
||
$('fieldNotes').style.display = '';
|
||
} else {
|
||
$('fieldNotes').style.display = 'none';
|
||
}
|
||
|
||
// TOTP
|
||
if (e.has_totp) {
|
||
$('fieldTotp').style.display = '';
|
||
loadDetailTotp(String(id));
|
||
} else {
|
||
$('fieldTotp').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function closeDetail() {
|
||
closeDetailTimers();
|
||
detailState = null;
|
||
$('detailPanel').style.display = 'none';
|
||
$('listWrap').style.display = '';
|
||
$('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 => {
|
||
chrome.runtime.sendMessage({ type: 'GET_PASSWORD', id }, resp => resolve(resp?.password ?? null));
|
||
});
|
||
if (detailState && detailState.id === String(id)) detailState.password = pw || '';
|
||
return pw || '';
|
||
}
|
||
|
||
async function toggleRevealPass() {
|
||
const pEl = $('detailPass');
|
||
if (detailState.revealPass) {
|
||
detailState.revealPass = false;
|
||
pEl.textContent = '••••••••••';
|
||
return;
|
||
}
|
||
pEl.textContent = '…';
|
||
const pw = await ensurePassword(detailState.id);
|
||
if (!detailState) return;
|
||
detailState.revealPass = true;
|
||
pEl.textContent = pw || '(leer)';
|
||
}
|
||
|
||
async function copyDetailPassword() {
|
||
const pw = await ensurePassword(detailState.id);
|
||
if (pw) copySecret(pw, 'Passwort kopiert');
|
||
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');
|
||
const barEl = $('detailTotpBar');
|
||
codeEl.textContent = '…';
|
||
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 (!resp?.code) { codeEl.textContent = '—'; return; }
|
||
apply(resp.code, resp.remaining);
|
||
|
||
detailState.totpInterval = setInterval(() => {
|
||
if (!stillOpen()) return;
|
||
if (render() === 0) refetch();
|
||
}, 1000);
|
||
});
|
||
}
|
||
|
||
async function fillActiveTab() {
|
||
if (!detailState) return;
|
||
const e = entryIndex[detailState.id];
|
||
if (!e) return;
|
||
const btn = $('btnDetailFill');
|
||
btn.disabled = true;
|
||
const pw = await ensurePassword(detailState.id);
|
||
|
||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||
const tab = tabs[0];
|
||
if (!tab || !tab.url || tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) {
|
||
showToast('Auf dieser Seite nicht möglich');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
// Sicherheit: Warnen, wenn die aktive Seite NICHT zur URL des Eintrags
|
||
// passt (verhindert versehentliches Ausfüllen auf einer fremden Domain).
|
||
if (!fillDomainMatches(e.url, tab.url)) {
|
||
const host = hostOf(tab.url);
|
||
if (!window.confirm('Diese Seite (' + host + ') passt nicht zur hinterlegten Adresse des Eintrags. Zugangsdaten trotzdem hier ausfüllen?')) {
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
}
|
||
chrome.tabs.sendMessage(tab.id, { type: 'VAULT_FILL', id: detailState.id, username: e.username || '', password: pw || '', has_totp: !!e.has_totp }, () => {
|
||
if (chrome.runtime.lastError) {
|
||
showToast('Seite nicht bereit – neu laden');
|
||
btn.disabled = false;
|
||
} else {
|
||
showToast('Ausgefüllt');
|
||
setTimeout(() => window.close(), 350);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Helfer ────────────────────────────────────────────────────────────────
|
||
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) {
|
||
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);
|
||
chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text });
|
||
}).catch(() => showToast('Fehler'));
|
||
}
|
||
function showToast(msg) {
|
||
const t = $('toast');
|
||
t.textContent = msg;
|
||
t.classList.add('show');
|
||
setTimeout(() => t.classList.remove('show'), 1800);
|
||
}
|
||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function escAttr(s) { return String(s||'').replace(/"/g,'"'); }
|
||
|
||
init(); |