'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
let editingId = null; // gesetzt, solange das Panel einen bestehenden Eintrag bearbeitet
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);
$('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'));
$('btnDetailFill').addEventListener('click', fillActiveTab);
$('btnDetailEdit').addEventListener('click', openEditPanel);
$('btnDetailDelete').addEventListener('click', deleteCurrentEntry);
// 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 = '
';
$('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 = '⚠ Nicht verbunden.
Einstellungen prüfen.
';
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 =
'Passend für diese Seite
' +
'' + pageMatches.map(e => entryHtml(e)).join('') + '
';
} else {
$('listWrap').innerHTML =
'Alle Einträge (' + allEntries.length + ')
' +
'';
}
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 =
'Suche (' + filtered.length + ')
' +
'';
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() {
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';
$('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() {
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.
for (let i = out.length - 1; i > 0; i--) {
const j = randomBelow(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() {
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(),
};
const msg = editingId
? { type: 'UPDATE_ENTRY', id: editingId, entry }
: { type: 'CREATE_ENTRY', entry };
const wasEditing = !!editingId;
chrome.runtime.sendMessage(msg, resp => {
$('btnSaveNew').disabled = false;
$('btnSaveNew').textContent = 'Speichern';
if (resp?.ok) {
closeNewPanel();
reload(true);
showToast(wasEditing ? 'Eintrag aktualisiert' : '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) || 'Kein Benutzername';
const m = monogram(e.title);
const icon = `${m.ch}`;
const totpBadge = e.has_totp ? '2FA' : '';
return `
${icon}
${esc(e.title)}${totpBadge}
${userText}
${esc(e.url) || ''}
${e.team_name ? `
${esc(e.team_name)}` : ''}
`;
}
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 = '
';
}
});
});
}
// ── 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, 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 = `${m.ch}`;
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 = '
';
}
});
}
// 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 – kein Geheimnis, daher immer im Klartext.
const uval = e.username || '';
const uEl = $('detailUser');
uEl.dataset.value = uval;
if (uval) {
uEl.classList.remove('empty');
uEl.textContent = uval;
$('btnCopyUser').style.display = '';
} else {
uEl.classList.add('empty');
uEl.textContent = 'Kein Benutzername';
$('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 = '';
}
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) { return VaultUrl.host(u); }
// 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 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,'>'); }
function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, '''); }
init();