Store-Build automatisch aus OpenNIT aktualisiert

This commit is contained in:
OpenNIT Extension Sync 2026-08-02 11:04:36 +00:00
parent baf13c865f
commit a032dd5b13
8 changed files with 295 additions and 487 deletions

View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
function escAttr(s) { return String(s||'').replace(/"/g,'&quot;'); }
init();