First Upload

This commit is contained in:
friloo 2026-07-01 18:26:57 +02:00 committed by GitHub
parent efc6fcbafa
commit c61677d47f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 3921 additions and 0 deletions

274
extension/background.js Normal file
View file

@ -0,0 +1,274 @@
'use strict';
// ── Cache ──────────────────────────────────────────────────────────────────
let cachedEntries = null;
let cacheTime = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
const faviconCache = new Map();
let pendingClip = null;
let refreshInFlight = null; // Single-Flight: verhindert parallele Refresh-Aufrufe (Rotation-Race)
async function getServerUrl() {
const c = await new Promise(r => chrome.storage.local.get(['serverUrl'], r));
return c.serverUrl || null;
}
// ── 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', '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) {
return sess.accessToken;
}
// Nur EINEN Refresh gleichzeitig ausführen; parallele Aufrufer warten mit.
if (!refreshInFlight) {
refreshInFlight = refreshAccessToken(cfg).finally(() => { refreshInFlight = null; });
}
return await refreshInFlight;
}
async function refreshAccessToken(cfg) {
try {
const body = new URLSearchParams();
body.append('grant_type', 'refresh_token');
body.append('refresh_token', cfg.apiRefreshToken);
const res = await fetch(`${cfg.serverUrl}/api/vault/extension/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
// ungültig / abgelaufen / Reuse-Detection → SSO-Tokens verwerfen (Re-Login nötig)
await chrome.storage.local.remove(['apiRefreshToken', 'apiRefreshExpiresAt']);
await chrome.storage.session.remove(['accessToken', 'accessExpiresAt']);
return null;
}
const data = await res.json();
// Rotation: den NEUEN Refresh-Token speichern.
await chrome.storage.local.set({
apiRefreshToken: data.refresh_token,
apiRefreshExpiresAt: Date.now() + (data.refresh_expires_in || 0) * 1000,
});
await chrome.storage.session.set({
accessToken: data.access_token,
accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000,
});
return data.access_token;
} catch (e) { return null; }
}
// Zentraler API-Aufruf: hängt Server-URL + gültigen Bearer an. null = nicht verfügbar.
async function apiFetch(path, opts = {}) {
const serverUrl = await getServerUrl();
const token = await getAccessToken();
if (!serverUrl || !token) return null;
const headers = Object.assign({}, opts.headers, { 'Authorization': 'Bearer ' + token });
return fetch(serverUrl + path, Object.assign({}, opts, { headers }));
}
// ── Lock-Gate (serverseitig erzwungen; Client spiegelt nur) ─────────────────
async function lockSettings() {
return new Promise(resolve => chrome.storage.local.get(['lockDuration', 'lockEnabled'], resolve));
}
function lockRequired(s) { return !!s.lockEnabled; }
function durationSecs(dur) {
switch (String(dur)) {
case '5': return 300;
case '60': return 3600;
case 'session': return 43200;
case 'off': return 900;
default: return 900;
}
}
async function isUnlocked() {
const s = await lockSettings();
if (!lockRequired(s)) return true;
const sess = await chrome.storage.session.get(['unlock']);
const u = sess.unlock;
if (!u) return false;
if (u.sticky) return true;
return !!u.until && Date.now() < u.until;
}
async function setUnlockedLocal(dur) {
const unlock = (String(dur) === 'session') ? { sticky: true } : { until: Date.now() + durationSecs(dur) * 1000 };
await chrome.storage.session.set({ unlock });
}
async function clearUnlocked() {
await chrome.storage.session.remove('unlock');
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');
cachedEntries = null; cacheTime = 0;
}
async function doUnlock(pin) {
const s = await lockSettings();
const dur = s.lockDuration || '15';
try {
const body = new URLSearchParams();
body.append('pin', pin);
body.append('duration_secs', String(durationSecs(dur)));
const res = await apiFetch('/api/vault/extension/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res) return { ok: false, error: 'Nicht konfiguriert.' };
const data = await res.json().catch(() => ({}));
if (data.ok) { await setUnlockedLocal(dur); return { ok: true }; }
return { ok: false, error: data.error || 'PIN falsch.', lockSecs: data.lock_secs || 0 };
} catch (e) { return { ok: false, error: 'Verbindungsfehler.' }; }
}
// ── Daten ───────────────────────────────────────────────────────────────────
async function fetchEntries(force = false) {
if (!(await isUnlocked())) return { entries: null, locked: true };
const now = Date.now();
if (!force && cachedEntries && (now - cacheTime) < CACHE_TTL) {
return { entries: cachedEntries, locked: false };
}
try {
const res = await apiFetch('/api/vault/extension/entries');
if (!res) return { entries: null, locked: false };
if (res.status === 423) { await onServerLocked(); return { entries: null, locked: true }; }
if (!res.ok) { cachedEntries = null; return { entries: null, locked: false }; }
const data = await res.json();
if (data.ok) {
cachedEntries = data.entries; cacheTime = Date.now();
return { entries: cachedEntries, locked: false };
}
} catch (e) { /* ignore */ }
return { entries: null, locked: false };
}
async function fetchPassword(entryId) {
if (!(await isUnlocked())) return null;
try {
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/password`);
if (!res) return null;
if (res.status === 423) { await onServerLocked(); return null; }
if (!res.ok) return null;
const data = await res.json();
return data.ok ? data.password : null;
} catch (e) { return null; }
}
async function fetchTotp(entryId) {
if (!(await isUnlocked())) return null;
try {
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/totp`);
if (!res) return null;
if (res.status === 423) { await onServerLocked(); return null; }
if (!res.ok) return null;
const data = await res.json();
return data.ok ? { code: data.code, remaining: data.remaining } : null;
} catch (e) { return null; }
}
async function fetchFavicon(entryId) {
if (faviconCache.has(entryId)) return faviconCache.get(entryId);
try {
const res = await apiFetch(`/api/vault/extension/entries/${entryId}/favicon?fetch=1`);
if (!res || !res.ok) { faviconCache.set(entryId, null); return null; }
const blob = await res.blob();
const dataUrl = await new Promise(resolve => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => resolve(null);
fr.readAsDataURL(blob);
});
faviconCache.set(entryId, dataUrl);
return dataUrl;
} catch (e) { return null; }
}
function matchUrl(entryUrl, pageUrl) {
if (!entryUrl) return false;
let pHost;
try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; }
return String(entryUrl).split('\n').some(line => {
const raw = line.trim();
if (!raw) return false;
try {
const eu = raw.includes('://') ? raw : 'https://' + raw;
let eHost = new URL(eu).hostname.replace(/^www\./, '');
if (eHost.startsWith('*.')) eHost = eHost.slice(2);
return pHost === eHost || pHost.endsWith('.' + eHost);
} catch { return false; }
});
}
// ── Zwischenablage automatisch leeren (Offscreen) ───────────────────────────
async function scheduleClipClear(text) {
const cfg = await new Promise(r => chrome.storage.local.get(['clipClear'], r));
if (cfg.clipClear === false) return;
pendingClip = text || '';
chrome.alarms.create('clipClear', { delayInMinutes: 0.5 });
}
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: 'Zwischenablage nach dem Kopieren von Zugangsdaten leeren.',
});
}
await chrome.runtime.sendMessage({ target: 'offscreen', type: 'CLIP_WRITE', text: '' });
} catch (e) { /* ignore */ }
}
// ── Status prüfen (Bearer) ──────────────────────────────────────────────────
async function checkStatus() {
const res = await apiFetch('/api/vault/extension/status');
if (!res) return { ok: false, reason: 'not_configured' };
try {
const data = await res.json();
if (data && typeof data.pin_enabled !== 'undefined') {
await new Promise(r => chrome.storage.local.set({ lockEnabled: !!data.pin_enabled }, r));
}
return data;
} catch { return { ok: false, reason: 'network_error' }; }
}
// ── Message Handler ──────────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!msg || msg.target === 'offscreen') return;
if (msg.type === 'GET_ENTRIES') {
fetchEntries(msg.force).then(r => sendResponse({ entries: r.entries, locked: r.locked }));
return true;
}
if (msg.type === 'GET_MATCHING_ENTRIES') {
fetchEntries().then(r => {
const matched = (r.entries || []).filter(e => matchUrl(e.url, msg.url));
sendResponse({ entries: matched, locked: r.locked });
});
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; }
if (msg.type === 'GET_LOCK') {
Promise.all([lockSettings(), isUnlocked()]).then(([s, unlocked]) => sendResponse({ required: lockRequired(s), unlocked }));
return true;
}
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 === '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; }
});
// Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout.
chrome.alarms.create('clearCache', { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'clearCache') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); return; }
if (alarm.name === 'clipClear') { await clearClipboard(); pendingClip = null; }
});

551
extension/content.js Normal file
View file

@ -0,0 +1,551 @@
'use strict';
/*
* OpenNIT Vault Content-Script
* Robuste Erkennung von Passwort-, Benutzer-/E-Mail- und TOTP-Feldern
* inkl. Shadow-DOM, dynamischen Formularen, mehrstufigen Logins und
* segmentierten OTP-Eingaben. Autofill via nativem Value-Setter + Events
* (framework-kompatibel: React/Vue/Angular).
*/
if (!window.__vaultInjected) {
window.__vaultInjected = true;
const DROPDOWN_ID = '__vault_dropdown__';
let appLabel = 'Vault';
let currentField = null;
let showGen = 0;
// ── 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;
const RE_USER_NEG = /(search|suche|query|coupon|promo|voucher|gift|zip|postal|plz|phone|tel|mobile|firstname|lastname|first[-_ ]?name|last[-_ ]?name|vorname|nachname|street|strasse|address|adresse|city|stadt|country|land|company|firma|captcha|amount|menge|quantity|qty)/i;
const RE_PASS = /(pass(word|wort)?|pwd|passwd|kennwort|passphrase)/i;
const RE_PASS_NEG = /(hint|frage|question|reminder|recovery|forgot|vergessen)/i;
const RE_OTP = /(otp|totp|2fa|mfa|one[-_ ]?time|einmal|verification|verify|verifizier|authenticat|auth[-_ ]?code|security[-_ ]?code|sms[-_ ]?code|passcode|one_?time_?code|2[-_ ]?step|two[-_ ]?factor|bestätigungscode|einmalkennwort|einmalpasswort)/i;
const RE_CODEONLY = /(\b|_)(code|pin|token)(\b|_)/i;
// ── kleine Helfer ───────────────────────────────────────────────────────────
function lc(s) { return String(s || '').toLowerCase(); }
function esc(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function vaultHue(s) { s = String(s || '?'); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }
function vaultClipCopy(text) {
navigator.clipboard.writeText(text).catch(() => {});
try { chrome.runtime.sendMessage({ type: 'SCHEDULE_CLIP_CLEAR', text: text }); } catch (e) { /* ignore */ }
}
function attr(el, n) { try { return el.getAttribute(n) || ''; } catch { return ''; } }
function ac(el) { return lc(attr(el, 'autocomplete')); }
function isVisible(el) {
if (!el) return false;
if (el.disabled || el.readOnly) return false;
if (lc(el.type) === 'hidden') return false;
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) return false;
const s = getComputedStyle(el);
if (s.display === 'none' || s.visibility === 'hidden' || s.visibility === 'collapse') return false;
if (parseFloat(s.opacity || '1') === 0) return false;
return true;
}
function labelText(el) {
const parts = [];
try {
if (el.id) {
const sel = (window.CSS && CSS.escape) ? CSS.escape(el.id) : el.id;
const l = document.querySelector('label[for="' + sel + '"]');
if (l) parts.push(l.textContent);
}
} catch {}
const wrap = el.closest ? el.closest('label') : null;
if (wrap) parts.push(wrap.textContent);
const lb = attr(el, 'aria-labelledby');
if (lb) lb.split(/\s+/).forEach(id => { const n = document.getElementById(id); if (n) parts.push(n.textContent); });
return parts.join(' ').slice(0, 200);
}
function sig(el) {
return lc([
el.name, el.id, attr(el, 'autocomplete'), el.placeholder,
attr(el, 'aria-label'), el.title, attr(el, 'data-testid'),
attr(el, 'ng-model'), el.className, labelText(el),
].join(' '));
}
function isTextLike(el) {
if (!el || el.tagName !== 'INPUT') return false;
return ['text', 'email', 'tel', 'search', 'url', 'number', ''].includes(lc(el.type || 'text'));
}
// ── Feld-Klassifikation ─────────────────────────────────────────────────────
function isPasswordField(el) {
if (!el || el.tagName !== 'INPUT') return false;
if (lc(el.type) === 'password') return true;
const a = ac(el);
if (a.includes('current-password') || a.includes('new-password')) return true;
// sichtbar geschaltetes Passwortfeld (type=text)
if (isTextLike(el)) {
const s = sig(el);
if (RE_PASS.test(s) && !RE_PASS_NEG.test(s) && !RE_USER.test(lc(el.name + ' ' + el.id))) return true;
}
return false;
}
function isOtpField(el) {
if (!el || el.tagName !== 'INPUT') return false;
const t = lc(el.type);
if (['password', 'checkbox', 'radio', 'submit', 'button', 'file', 'hidden', 'range', 'color', 'date'].includes(t)) return false;
if (ac(el).includes('one-time-code')) return true;
const s = sig(el);
const ml = parseInt(attr(el, 'maxlength') || '0', 10);
const pat = lc(attr(el, 'pattern'));
const numeric = lc(el.inputMode || '') === 'numeric' || pat.includes('0-9') || pat.includes('\\d') || t === 'number' || t === 'tel';
if (RE_OTP.test(s)) return true;
if (RE_CODEONLY.test(s) && (numeric || (ml > 0 && ml <= 8))) return true;
// segmentierte OTP-Eingabe (mehrere 1-Zeichen-Felder)
if (ml === 1 && numeric) return segmentGroup(el).length >= 4;
return false;
}
function isUsernameField(el) {
if (!el || el.tagName !== 'INPUT') return false;
const t = lc(el.type || 'text');
if (['password', 'submit', 'button', 'hidden', 'checkbox', 'radio', 'file', 'image', 'range', 'color', 'date', 'datetime-local', 'month', 'week', 'time'].includes(t)) return false;
if (isOtpField(el)) return false;
const a = ac(el);
if (a.includes('username') || a === 'email') return true;
if (t === 'email') return true;
const s = sig(el);
return RE_USER.test(s) && !RE_USER_NEG.test(s);
}
function isLoginField(el) { return isPasswordField(el) || isUsernameField(el) || isOtpField(el); }
function fieldKind(el) {
if (isPasswordField(el)) return 'password';
if (isOtpField(el)) return 'otp';
if (isUsernameField(el)) return 'username';
return null;
}
// ── Shadow-DOM-fähige Feldsammlung ──────────────────────────────────────────
function collectInputs(container) {
const out = [];
const visit = (root) => {
let nodes;
try { nodes = root.querySelectorAll('input, textarea'); } catch { nodes = []; }
nodes.forEach(n => out.push(n));
let all;
try { all = root.querySelectorAll('*'); } catch { all = []; }
all.forEach(n => { if (n.shadowRoot) visit(n.shadowRoot); });
};
visit(container || document);
return out;
}
function scopeOf(field) {
const form = field.closest ? field.closest('form') : null;
if (form) return form;
const root = field.getRootNode ? field.getRootNode() : null;
if (root && root.host && root.host.closest) {
const f = root.host.closest('form');
if (f) return f;
}
return document.body;
}
function segmentGroup(el) {
const parent = el.parentElement;
if (!parent) return [el];
const sibs = [...parent.querySelectorAll('input')].filter(i => parseInt(attr(i, 'maxlength') || '0', 10) === 1);
return sibs.length >= 4 ? sibs : [el];
}
function findUsernameField(ref) {
const inputs = collectInputs(scopeOf(ref)).filter(isVisible);
const idx = inputs.indexOf(ref);
for (let i = idx - 1; i >= 0; i--) if (isUsernameField(inputs[i])) return inputs[i];
for (let i = idx + 1; i < inputs.length; i++) if (isUsernameField(inputs[i])) return inputs[i];
// positionaler Fallback: Textfeld direkt vor dem Passwort
for (let i = idx - 1; i >= 0; i--) if (isTextLike(inputs[i]) && !isOtpField(inputs[i])) return inputs[i];
return null;
}
function findPasswordField(ref) {
const inputs = collectInputs(scopeOf(ref));
const vis = inputs.filter(isVisible);
return vis.find(isPasswordField) || inputs.find(isPasswordField) || null;
}
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('keydown', onKeyDown, true);
document.addEventListener('click', onDocClick, true);
window.addEventListener('scroll', repositionDrop, true);
window.addEventListener('resize', repositionDrop, true);
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg && msg.type === 'VAULT_FILL') {
fillFromPopup(msg);
sendResponse({ ok: true });
return true;
}
});
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, resp => { if (resp && resp.app_name) appLabel = resp.app_name; });
}
// Vom Popup angestoßenes Ausfüllen (ohne fokussiertes Feld): bestes
// Passwort-/Benutzerfeld der Seite suchen und befüllen.
function fillFromPopup(msg) {
const inputs = collectInputs(document).filter(isVisible);
const passField = inputs.find(isPasswordField) || null;
let userField = passField ? findUsernameField(passField) : null;
if (!userField) userField = inputs.find(isUsernameField) || null;
if (userField && msg.username) setFieldValue(userField, msg.username);
if (passField && msg.password) setFieldValue(passField, msg.password);
else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } });
if (msg.has_totp && msg.id != null) {
const otps = findOtpFields(passField || userField || document.body);
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: msg.id }, t => {
if (!t || !t.code) return;
if (otps.length) distributeOtp(otps, t.code);
vaultClipCopy(t.code);
showTotpNotification(t.code, t.remaining);
});
}
}
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);
}
function maybeShow(el) {
if (!isLoginField(el) || !isVisible(el)) return;
currentField = el;
showSuggestions(el);
}
function onFocusOut(e) {
const blurred = e.target;
setTimeout(() => {
const active = document.activeElement;
const drop = document.getElementById(DROPDOWN_ID);
if (active === blurred || active === currentField || (drop && drop.contains(active))) return;
hideDrop();
currentField = null;
}, 200);
}
function onDocClick(e) {
const drop = document.getElementById(DROPDOWN_ID);
if (drop && drop.contains(e.target)) return;
if (e.target === currentField) return;
hideDrop();
}
function onKeyDown(e) {
const drop = document.getElementById(DROPDOWN_ID);
if (!drop) return;
const items = [...drop.querySelectorAll('.vi')];
if (!items.length) return;
let idx = items.findIndex(i => i.classList.contains('selected'));
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(items, idx + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(items, idx - 1); }
else if (e.key === 'Enter' && idx >= 0) { e.preventDefault(); items[idx].click(); }
else if (e.key === 'Escape') { hideDrop(); currentField = null; }
}
function setSelected(items, idx) {
items.forEach(i => i.classList.remove('selected'));
const next = items[Math.max(0, Math.min(idx, items.length - 1))];
if (next) { next.classList.add('selected'); next.scrollIntoView({ block: 'nearest' }); }
}
// ── Vorschläge ──────────────────────────────────────────────────────────────
function showSuggestions(field) {
const gen = ++showGen;
const mode = fieldKind(field) === 'otp' ? 'otp' : 'login';
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => {
if (gen !== showGen) return;
let entries = (resp && resp.entries || []).filter(e => matchUrl(e.url, location.href));
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);
});
}
function repositionDrop() {
const drop = document.getElementById(DROPDOWN_ID);
if (!drop || !currentField) return;
const r = currentField.getBoundingClientRect();
if (r.width === 0) { hideDrop(); return; }
drop.style.top = (r.bottom + 2) + 'px';
drop.style.left = r.left + 'px';
drop.style.width = Math.max(r.width, 300) + 'px';
}
function renderDrop(field, entries, mode) {
hideDrop();
const rect = field.getBoundingClientRect();
if (rect.width === 0) return;
const drop = document.createElement('div');
drop.id = DROPDOWN_ID;
Object.assign(drop.style, {
position: 'fixed', top: (rect.bottom + 4) + 'px', left: rect.left + 'px',
width: Math.max(rect.width, 300) + 'px', background: '#fff', border: '1px solid #e3e6ef',
borderRadius: '12px', boxShadow: '0 10px 32px rgba(31,35,48,.20)', zIndex: '2147483647',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif', fontSize: '13px',
overflow: 'hidden', maxHeight: '320px', overflowY: 'auto', color: '#1f2330',
});
// Hover-/Auswahl-Highlight (scoped auf unser Dropdown page-safe)
const styleEl = document.createElement('style');
styleEl.textContent = '#' + DROPDOWN_ID + ' .vi:hover,#' + DROPDOWN_ID + ' .vi.selected{background:#f5f6fb !important;}';
drop.appendChild(styleEl);
const hd = document.createElement('div');
Object.assign(hd.style, {
padding: '9px 13px', background: 'linear-gradient(135deg,#4f46e5 0%,#5b6ee8 45%,#3c8dbc 100%)',
color: '#fff', fontWeight: '700', fontSize: '10.5px',
display: 'flex', alignItems: 'center', gap: '7px', letterSpacing: '.05em', textTransform: 'uppercase',
});
hd.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> '
+ esc(appLabel) + (mode === 'otp' ? ' &middot; 2FA' : ' &middot; Vault');
drop.appendChild(hd);
entries.forEach(entry => {
const item = document.createElement('div');
item.className = 'vi';
item.dataset.id = entry.id;
Object.assign(item.style, {
padding: '9px 13px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px',
borderBottom: '1px solid #f1f3f5', background: '#fff', transition: 'background .1s',
});
const _hue = vaultHue(entry.title || '?');
const favSpan = document.createElement('span');
Object.assign(favSpan.style, {
width: '22px', height: '22px', borderRadius: '6px', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', fontSize: '11px', fontWeight: '700', flexShrink: '0', overflow: 'hidden',
background: 'hsl(' + _hue + ',52%,90%)', color: 'hsl(' + _hue + ',55%,38%)',
});
favSpan.textContent = (entry.title || '?').charAt(0).toUpperCase();
// Serverseitig gecachtes Favicon nachladen (kein externer Call)
if (entry.has_favicon) {
chrome.runtime.sendMessage({ type: 'GET_FAVICON', id: entry.id }, r => {
if (r && r.dataUrl) {
favSpan.style.background = '#eef0f7';
favSpan.innerHTML = '<img src="' + r.dataUrl + '" alt="" style="width:16px;height:16px;object-fit:contain;">';
}
});
}
const team = entry.team_name
? '<span style="font-size:9px;background:#ede9fe;color:#6d28d9;border-radius:5px;padding:1.5px 6px;white-space:nowrap;flex-shrink:0;font-weight:700;">' + esc(entry.team_name) + '</span>'
: '';
const sub = mode === 'otp'
? '<span style="color:#4f46e5;font-size:11px;font-weight:600;">2FA-Code einfügen</span>'
: '<div style="color:#79839a;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + (esc(entry.username) || '<em style="color:#aab2c3">Kein Benutzername</em>') + '</div>';
const info = document.createElement('div');
info.style.cssText = 'flex:1;min-width:0;';
info.innerHTML = '<div style="font-weight:600;color:#1f2330;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px;">'
+ esc(entry.title) + '</div>' + sub;
item.appendChild(favSpan);
item.appendChild(info);
if (team) {
const t = document.createElement('span');
t.innerHTML = team;
item.appendChild(t.firstChild);
}
item.addEventListener('mouseenter', () => {
[...drop.querySelectorAll('.vi')].forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
});
// optionaler 2FA-Chip im Login-Modus
if (mode === 'login' && entry.has_totp) {
const chip = document.createElement('button');
Object.assign(chip.style, {
background: '#e0f2fe', border: '1px solid #bae0fd', borderRadius: '6px', padding: '2px 7px',
fontSize: '9px', fontWeight: '700', color: '#0369a1', cursor: 'pointer', flexShrink: '0',
letterSpacing: '.03em', fontFamily: 'inherit',
});
chip.textContent = '2FA';
chip.title = '2FA-Code kopieren';
chip.addEventListener('mousedown', ev => {
ev.preventDefault(); ev.stopPropagation();
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, r => {
if (r && r.code) { vaultClipCopy(r.code); showTotpNotification(r.code, r.remaining); }
});
});
item.appendChild(chip);
}
item.addEventListener('mousedown', ev => {
if (ev.target.tagName === 'BUTTON') return;
ev.preventDefault(); ev.stopPropagation();
if (mode === 'otp') fillOtp(field, entry);
else fillEntry(entry, field);
hideDrop();
});
drop.appendChild(item);
});
const ft = document.createElement('div');
Object.assign(ft.style, { padding: '5px 12px', color: '#79839a', fontSize: '10px', textAlign: 'center', background: '#f6f7fb', borderTop: '1px solid #edeff4' });
ft.innerHTML = '&uarr;&darr; Navigieren &middot; Enter Ausw&auml;hlen &middot; Esc Schlie&szlig;en';
drop.appendChild(ft);
document.documentElement.appendChild(drop);
}
function hideDrop() {
const d = document.getElementById(DROPDOWN_ID);
if (d) d.remove();
}
// ── Befüllen ────────────────────────────────────────────────────────────────
async function fillEntry(entry, focused) {
let pw = '';
try { const r = await chrome.runtime.sendMessage({ type: 'GET_PASSWORD', id: entry.id }); pw = (r && r.password) || ''; } catch {}
const kind = fieldKind(focused) || 'username';
let userField = null, passField = null;
if (kind === 'password') { passField = focused; userField = findUsernameField(focused); }
else { userField = focused; passField = findPasswordField(focused); }
if (userField && entry.username) setFieldValue(userField, entry.username);
if (passField) setFieldValue(passField, pw);
else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } });
if (entry.has_totp) {
const otps = findOtpFields(passField || userField || focused);
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, t => {
if (!t || !t.code) return;
if (otps.length) distributeOtp(otps, t.code);
vaultClipCopy(t.code);
showTotpNotification(t.code, t.remaining);
});
}
}
function fillOtp(field, entry) {
chrome.runtime.sendMessage({ type: 'GET_TOTP', id: entry.id }, t => {
if (!t || !t.code) return;
const group = segmentGroup(field);
if (group.length >= 4) distributeOtp(group, t.code);
else setFieldValue(field, t.code);
vaultClipCopy(t.code);
showTotpNotification(t.code, t.remaining);
});
}
function distributeOtp(fields, code) {
const digits = String(code).replace(/\s+/g, '').split('');
if (fields.length >= digits.length && fields.length > 1) {
fields.forEach((f, i) => setFieldValue(f, digits[i] || ''));
const last = fields[Math.min(digits.length, fields.length) - 1];
if (last) last.focus({ preventScroll: true });
} else {
setFieldValue(fields[0], String(code).replace(/\s+/g, ''));
}
}
function setFieldValue(field, value) {
try {
field.focus({ preventScroll: true });
const proto = (typeof HTMLTextAreaElement !== 'undefined' && field instanceof HTMLTextAreaElement)
? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) setter.set.call(field, value); else field.value = value;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));
field.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }));
field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
field.dispatchEvent(new Event('blur', { bubbles: true }));
} catch {}
}
// ── 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');
});
});
try { _obs.observe(document.documentElement, { childList: true, subtree: true }); } catch {}
// ── TOTP-Benachrichtigung (unten rechts) ────────────────────────────────────
function showTotpNotification(code, remaining) {
const ID = '__vault_totp_notif__';
const old = document.getElementById(ID);
if (old) old.remove();
const formatted = String(code).length === 6 ? code.slice(0, 3) + ' ' + code.slice(3) : code;
const notif = document.createElement('div');
notif.id = ID;
Object.assign(notif.style, {
position: 'fixed', bottom: '18px', right: '18px', background: '#212529', color: '#fff',
padding: '10px 14px', borderRadius: '8px', fontSize: '12px',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', zIndex: '2147483647',
boxShadow: '0 4px 16px rgba(0,0,0,.35)', display: 'flex', flexDirection: 'column', gap: '6px',
minWidth: '180px', cursor: 'pointer',
});
notif.innerHTML =
'<div style="display:flex;align-items:center;gap:7px;"><span style="font-size:14px;">🔑</span>'
+ '<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:' + (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);
let secs = remaining;
const iv = setInterval(() => {
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; }
t.textContent = secs + 's';
if (bar) { bar.style.width = (secs / 30 * 100) + '%'; if (secs < 10) bar.style.background = '#dc3545'; }
}, 1000);
notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); });
}
init();
}

BIN
extension/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
extension/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

BIN
extension/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
extension/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

48
extension/manifest.json Normal file
View file

@ -0,0 +1,48 @@
{
"manifest_version": 3,
"name": "OpenNIT Vault",
"version": "2.4.1",
"description": "OpenNIT Vault Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.",
"permissions": [
"storage",
"activeTab",
"scripting",
"alarms",
"offscreen",
"identity"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "OpenNIT Vault",
"default_icon": {
"16": "icon16.png",
"32": "icon32.png"
}
},
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content.js"
],
"run_at": "document_idle"
}
],
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}

1
extension/offscreen.html Normal file
View file

@ -0,0 +1 @@
<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><textarea id="t"></textarea><script src="offscreen.js"></script></body></html>

17
extension/offscreen.js Normal file
View file

@ -0,0 +1,17 @@
'use strict';
chrome.runtime.onMessage.addListener((msg) => {
if (!msg || msg.target !== 'offscreen') return;
if (msg.type === 'CLIP_WRITE') {
const text = msg.text || '';
// Bevorzugt die Clipboard-API; Fallback über execCommand.
Promise.resolve()
.then(() => navigator.clipboard.writeText(text))
.catch(() => {
const ta = document.getElementById('t');
ta.value = text || ' ';
ta.select();
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
ta.value = '';
});
}
});

304
extension/options.html Normal file
View file

@ -0,0 +1,304 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenNIT Vault &ndash; Einstellungen</title>
<style>
/* ── Reset & Base ──────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
color: #212529;
background: #f4f6f9;
margin: 0; padding: 0;
min-height: 100vh;
}
/* ── Main navbar (AdminLTE style) ─────────────────────────────────────── */
.main-header {
background: #343a40;
border-bottom: 3px solid #007bff;
padding: 0 1.25rem;
height: 56px;
display: flex;
align-items: center;
gap: 1rem;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 6px rgba(0,0,0,.25);
}
.brand-logo {
width: 36px; height: 36px;
border-radius: 6px;
background: none;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.brand-logo img { width: 26px; height: 26px; object-fit: contain; }
.brand-text {
color: #fff;
font-size: 1rem;
font-weight: 700;
letter-spacing: .01em;
white-space: nowrap;
}
.brand-sep { color: rgba(255,255,255,.35); margin: 0 2px; font-weight: 300; }
.brand-sub { color: #adb5bd; font-weight: 400; font-size: .95rem; }
.header-user {
margin-left: auto;
display: flex;
align-items: center;
gap: .5rem;
color: #dee2e6;
font-size: .82rem;
}
.user-badge {
background: rgba(255,255,255,.1);
border: 1px solid rgba(255,255,255,.15);
border-radius: 20px;
padding: 3px 10px 3px 6px;
display: flex;
align-items: center;
gap: 5px;
font-size: .78rem;
}
.user-dot { width: 7px; height: 7px; background: #28a745; border-radius: 50%; }
/* ── Content wrapper ──────────────────────────────────────────────────── */
.content-wrapper { max-width: 680px; margin: 0 auto; padding: 1.5rem 1.25rem; }
/* ── Card (AdminLTE .card) ───────────────────────────────────────────── */
.card {
background: #fff;
border: none;
border-radius: .35rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
margin-bottom: 1.25rem;
}
.card-header {
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: .75rem 1.25rem;
border-radius: .35rem .35rem 0 0;
display: flex;
align-items: center;
gap: .5rem;
}
.card-header h3 {
margin: 0;
font-size: .9rem;
font-weight: 600;
color: #343a40;
line-height: 1;
}
.card-header .card-tools { margin-left: auto; }
.card-body { padding: 1.25rem; }
/* ── Form controls ───────────────────────────────────────────────────── */
.form-group { margin-bottom: 1rem; }
.form-group:last-child { margin-bottom: 0; }
label.control-label {
display: block;
font-size: .78rem;
font-weight: 700;
color: #6c757d;
text-transform: uppercase;
letter-spacing: .04em;
margin-bottom: .35rem;
}
.form-control {
display: block;
width: 100%;
padding: .45rem .75rem;
font-size: .9rem;
color: #495057;
background: #fff;
border: 1px solid #ced4da;
border-radius: .25rem;
transition: border-color .15s, box-shadow .15s;
}
.form-control:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 .2rem rgba(0,123,255,.25);
}
.form-text { font-size: .78rem; color: #6c757d; margin-top: .25rem; line-height: 1.5; }
/* ── Buttons ─────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .4rem .85rem;
font-size: .875rem;
font-weight: 600;
border: 1px solid transparent;
border-radius: .25rem;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
text-decoration: none;
}
.btn-primary { background: #007bff; border-color: #007bff; color: #fff; }
.btn-primary:hover { background: #0069d9; border-color: #0062cc; }
.btn-default { background: #fff; border-color: #ced4da; color: #212529; }
.btn-default:hover { background: #f8f9fa; border-color: #adb5bd; }
.btn-sm { padding: .28rem .65rem; font-size: .8rem; }
.btn-group { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin-top: .75rem; }
/* ── Alerts ──────────────────────────────────────────────────────────── */
.alert { padding: .65rem 1rem; border-radius: .25rem; font-size: .85rem; margin-top: .75rem; border: 1px solid transparent; }
.alert-success { background: #d4edda; color: #155724; border-color: #c3e6cb; }
.alert-danger { background: #f8d7da; color: #721c24; border-color: #f5c6cb; }
.alert-info { background: #d1ecf1; color: #0c5460; border-color: #bee5eb; }
.save-ok { color: #28a745; font-size: .82rem; font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }
/* ── Spinner ─────────────────────────────────────────────────────────── */
.fa-spin-sm {
display: inline-block;
width: 12px; height: 12px;
border: 2px solid #dee2e6;
border-top-color: #007bff;
border-radius: 50%;
animation: sp .7s linear infinite;
vertical-align: middle;
}
@keyframes sp { to { transform: rotate(360deg); } }
/* ── Steps list ──────────────────────────────────────────────────────── */
.steps-list { list-style: none; margin: 0; padding: 0; }
.steps-list li {
display: flex;
gap: .875rem;
align-items: flex-start;
padding: .5rem 0;
border-bottom: 1px solid #f1f3f5;
font-size: .875rem;
color: #495057;
line-height: 1.55;
}
.steps-list li:last-child { border-bottom: none; padding-bottom: 0; }
.step-badge {
min-width: 22px; height: 22px;
background: #007bff;
color: #fff;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: .7rem;
font-weight: 700;
flex-shrink: 0;
margin-top: .15rem;
}
</style>
</head>
<body>
<!-- Main Header -->
<nav class="main-header">
<div class="brand-logo"><img src="icon128.png" alt=""></div>
<span class="brand-text" id="optTitle">OpenNIT Vault</span>
<div class="header-user" id="headerStatus" style="display:none;">
<span class="user-badge">
<span class="user-dot"></span>
<span id="headerUser"></span>
</span>
</div>
</nav>
<div class="content-wrapper">
<!-- Connection card -->
<div class="card">
<div class="card-header">
<h3>Server-Verbindung</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="control-label" for="serverUrl">Server-URL</label>
<input type="url" class="form-control" id="serverUrl"
placeholder="https://ihre-opennit.firma.de"
value="">
<span class="form-text">Adresse Ihrer OpenNIT-Instanz &ndash; ohne abschlie&szlig;enden Slash.</span>
</div>
<!-- SSO-Anmeldung (empfohlen) -->
<div class="btn-group">
<button class="btn btn-primary" id="btnSso">
<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="btnLogout" style="display:none;">Abmelden</button>
<span id="ssoMsg"></span>
</div>
<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&uuml;gen&hellip;">
<span class="form-text">Alternative ohne SSO (z. B. Kiosk/Headless): Vault &rarr; &bdquo;Extension&ldquo; &rarr; 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>
<!-- Security card -->
<div class="card">
<div class="card-header">
<h3>Sicherheit</h3>
</div>
<div class="card-body">
<div class="form-group">
<label class="control-label" for="lockDuration">PIN-Sperre: erneut fragen</label>
<select class="form-control" id="lockDuration">
<option value="5">Nach 5 Minuten</option>
<option value="15">Nach 15 Minuten</option>
<option value="60">Nach 1 Stunde</option>
<option value="session">Bis der Browser geschlossen wird</option>
</select>
<span class="form-text">Ist im Web-Tresor ein <strong>Tresor-PIN</strong> aktiv, verlangt die Erweiterung diesen PIN <strong>serverseitig erzwungen</strong>, damit ein gestohlener Token allein nichts nützt. Diese Einstellung legt nur fest, <em>wie lange</em> eine Entsperrung gilt. Ohne gesetzten Tresor-PIN entfällt die Sperre.</span>
</div>
<div class="form-group">
<label class="control-label"><input type="checkbox" id="clipClear" checked> Zwischenablage nach 30&nbsp;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="btn-group">
<button class="btn btn-primary btn-sm" id="btnSaveSec">Speichern</button>
<span id="savedSecMsg"></span>
</div>
</div>
</div>
<!-- Setup card -->
<div class="card">
<div class="card-header">
<h3>Einrichtungsschritte</h3>
</div>
<div class="card-body">
<ul class="steps-list">
<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>&bdquo;Extension&ldquo;</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 &ndash; Vault-Vorschl&auml;ge erscheinen automatisch.</span></li>
</ul>
</div>
</div>
</div><!-- /.content-wrapper -->
<script src="options.js"></script>
</body>
</html>

227
extension/options.js Normal file
View file

@ -0,0 +1,227 @@
'use strict';
function $(id) { return document.getElementById(id); }
// Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert)
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'], 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,
}, () => {
chrome.runtime.sendMessage({ type: 'LOCK_NOW' });
$('savedSecMsg').innerHTML = '<span class="save-ok">&#10003; Gespeichert</span>';
setTimeout(() => { $('savedSecMsg').textContent = ''; }, 2000);
});
});
// 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);
if (u.protocol === 'https:') return true;
if (u.protocol === 'http:' && /^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname)) return true;
return false;
} catch { return false; }
}
$('btnSave').addEventListener('click', () => {
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 Token und Passwörter unverschlüsselt übertragen.', false);
return;
}
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">&#10003; 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&#8230;';
$('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)));
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function randB64(len) { const a = new Uint8Array(len); crypto.getRandomValues(a); return b64url(a); }
async function pkceChallenge(verifier) {
const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return b64url(d);
}
function ssoMsg(msg, ok) {
const el = $('ssoMsg');
if (ok === null) { el.innerHTML = msg ? ('<span style="color:#6c757d;font-size:.82rem;">' + esc(msg) + '</span>') : ''; return; }
el.innerHTML = ok ? ('<span class="save-ok">&#10003; ' + esc(msg) + '</span>')
: ('<span style="color:#dc3545;font-size:.82rem;">' + esc(msg) + '</span>');
if (ok) setTimeout(() => { el.innerHTML = ''; }, 3000);
}
function ssoSet(area, obj) { return new Promise(r => chrome.storage[area].set(obj, r)); }
function ssoRemove(area, keys) { return new Promise(r => chrome.storage[area].remove(keys, r)); }
async function loginWithSso() {
const url = $('serverUrl').value.trim().replace(/\/$/, '');
if (!url) { ssoMsg('Bitte zuerst die Server-URL eingeben.', false); return; }
if (!isSecureServerUrl(url)) { ssoMsg('Bitte eine https://-Adresse verwenden.', false); return; }
if (!chrome.identity || !chrome.identity.launchWebAuthFlow) { ssoMsg('Anmeldung wird von diesem Browser nicht unterstützt.', false); return; }
const verifier = randB64(48);
const challenge = await pkceChallenge(verifier);
const state = randB64(16);
const redirectUri = chrome.identity.getRedirectURL();
const authUrl = url + '/vault/extension/authorize?' + new URLSearchParams({
client_id: 'opennit-vault-extension', redirect_uri: redirectUri, response_type: 'code',
code_challenge: challenge, code_challenge_method: 'S256', state: state, scope: 'vault',
}).toString();
$('btnSso').disabled = true;
ssoMsg('Anmeldung läuft…', null);
console.log('[OpenNIT Vault] Auth-URL:', authUrl, '| redirect_uri:', redirectUri);
chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, async (redirect) => {
$('btnSso').disabled = false;
const le = chrome.runtime.lastError ? (chrome.runtime.lastError.message || 'unbekannt') : null;
console.log('[OpenNIT Vault] launchWebAuthFlow zurück:', { lastError: le, redirect: redirect || null });
if (le || !redirect) {
ssoMsg('Anmeldung abgebrochen' + (le ? ' ' + le : ' (keine Rückmeldung)') + '.', false);
return;
}
let params;
try { params = new URL(redirect).searchParams; } catch { ssoMsg('Ungültige Antwort.', false); return; }
if (params.get('error')) { ssoMsg('Abgelehnt (' + params.get('error') + ').', false); return; }
if (params.get('state') !== state) { ssoMsg('Sicherheitsprüfung fehlgeschlagen (state).', false); return; }
const code = params.get('code');
if (!code) { ssoMsg('Kein Autorisierungscode erhalten.', false); return; }
try {
const body = new URLSearchParams({ grant_type: 'authorization_code', code: code, code_verifier: verifier, redirect_uri: redirectUri });
const res = await fetch(url + '/api/vault/extension/oauth/token', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(),
});
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);
reflectAuthState();
loadConnStatus();
} catch (e) { ssoMsg('Verbindungsfehler: ' + e.message, false); }
});
}
async function logoutSso() {
const url = $('serverUrl').value.trim().replace(/\/$/, '');
const cfg = await new Promise(r => chrome.storage.local.get(['apiRefreshToken'], r));
if (url && cfg.apiRefreshToken) {
try {
const b = new URLSearchParams({ token: cfg.apiRefreshToken });
await fetch(url + '/api/vault/extension/oauth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() });
} catch (e) { /* ignore */ }
}
await ssoRemove('local', ['apiRefreshToken', 'apiRefreshExpiresAt']);
await ssoRemove('session', ['accessToken', 'accessExpiresAt', 'unlock']);
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
reflectAuthState();
ssoMsg('Abgemeldet.', true);
$('headerStatus').style.display = 'none';
}
function reflectAuthState() {
chrome.storage.local.get(['apiRefreshToken'], cfg => {
const sso = !!cfg.apiRefreshToken;
$('btnLogout').style.display = sso ? '' : 'none';
$('btnSso').lastChild.textContent = sso ? ' Neu anmelden' : ' Mit OpenNIT anmelden';
});
}
// Verbindungsstatus über den Background (nutzt SSO-Access-Token oder manuellen Token)
function loadConnStatus() {
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => {
if (data && data.ok) {
if (data.app_name) { $('optTitle').textContent = 'OpenNIT Vault'; }
if (data.user) { $('headerUser').textContent = data.app_name ? (data.user + ' · ' + data.app_name) : data.user; $('headerStatus').style.display = ''; }
}
});
}
$('btnSso').addEventListener('click', loginWithSso);
$('btnLogout').addEventListener('click', logoutSso);
reflectAuthState();
loadConnStatus();
function showStatus(msg, ok) {
const el = $('statusMsg');
el.innerHTML = `<div class="alert ${ok ? 'alert-success' : 'alert-danger'}">${msg}</div>`;
setTimeout(() => { el.innerHTML = ''; }, 5000);
}
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

347
extension/popup.html Normal file
View file

@ -0,0 +1,347 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#f6f7fb; --card:#ffffff; --ink:#1f2330; --muted:#79839a;
--line:#edeff4; --brand:#4f46e5; --brand2:#3c8dbc; --ok:#16a34a; --danger:#dc2626;
--radius:12px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg:#15171f; --card:#1e212b; --ink:#e7e9f0; --muted:#9aa2b5;
--line:#2b2f3b; --brand:#6366f1; --brand2:#3c8dbc; --ok:#22c55e; --danger:#f87171;
}
.entry-icon, .detail-icon { background:#262a36; }
.ico-btn, .field-btn, .ne-icon-btn { background:#262a36; color:#c4c9d6; }
.search-input, .panel-input, .lock-input { background:#262a36; color:var(--ink); }
.totp-display, .totp-code { color:#fff; }
.team-badge { background:#312e54; color:#c7c2f5; }
}
body {
width: 360px;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
font-size: 13px; color: var(--ink); background: var(--bg);
-webkit-font-smoothing: antialiased;
}
/* ── Header ─────────────────────────────────────────────── */
.hd {
background: linear-gradient(135deg,#4f46e5 0%,#5b6ee8 45%,#3c8dbc 100%);
color: #fff; padding: 13px 14px; display: flex; align-items: center; gap: 11px;
}
.hd-icon { width: 30px; height: 30px; border-radius: 9px; background: rgba(255,255,255,.18);
display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; }
.hd-icon img { width: 22px; height: 22px; object-fit: contain; }
.hd-main { flex: 1; min-width: 0; }
.hd-title { font-weight: 700; font-size: 13.5px; letter-spacing: .01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hd-user { font-size: 10.5px; color: rgba(255,255,255,.78); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.hd-dot { width: 8px; height: 8px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,.25); flex-shrink: 0; }
/* ── Search ─────────────────────────────────────────────── */
.search-wrap { padding: 10px 12px 8px; background: var(--bg); }
.search-input {
width: 100%; padding: 9px 12px 9px 34px;
border: 1.5px solid var(--line); border-radius: 10px; font-size: 12.5px; outline: none;
background: var(--card) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='13' viewBox='0 0 24 24' fill='none' stroke='%2379839a' stroke-width='2.2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E") 12px center/13px no-repeat;
transition: border-color .15s, box-shadow .15s;
}
.search-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.14); }
/* ── Section labels ─────────────────────────────────────── */
.section-lbl {
padding: 6px 14px 4px; font-size: 10px; font-weight: 700; color: var(--muted);
text-transform: uppercase; letter-spacing: .07em;
}
.section-lbl.match { color: var(--brand); }
/* ── Entries ────────────────────────────────────────────── */
.entries { padding: 0 8px 4px; overflow-y: auto; }
.entries.scrollable { max-height: 256px; }
.entry {
padding: 9px 10px; margin: 3px 0; border-radius: 10px;
display: flex; align-items: center; gap: 11px; cursor: pointer; background: var(--card);
border: 1.5px solid transparent; transition: border-color .12s, box-shadow .12s, transform .04s;
}
.entry:hover { border-color: #e3e6ef; box-shadow: 0 4px 14px rgba(31,35,48,.07); }
.entry:active { transform: scale(.995); }
.entry.kbd-sel { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(79,70,229,.16); }
.entry-2fa { font-size: 8.5px; font-weight: 700; color: #0369a1; background: #e0f2fe; border-radius: 4px; padding: 1px 4px; margin-left: 6px; vertical-align: middle; letter-spacing: .03em; }
.entry-chev { color: #c2c8d6; flex-shrink: 0; }
.entry:hover .entry-chev, .entry.kbd-sel .entry-chev { color: var(--brand); }
.entry-icon {
width: 32px; height: 32px; border-radius: 9px; background: #eef0f7;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; flex-shrink: 0; overflow: hidden;
}
.entry-icon img { width: 20px; height: 20px; object-fit: contain; }
.entry-info { flex: 1; min-width: 0; }
.entry-title { font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12.5px; }
.entry-user { color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.entry-meta { display: flex; gap: 5px; align-items: center; margin-top: 2px; }
.entry-url { color: #aab2c3; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
.entry-actions { display: flex; gap: 4px; opacity: 0; transition: opacity .12s; flex-shrink: 0; }
.entry:hover .entry-actions { opacity: 1; }
.ico-btn { background: #f3f4f8; border: none; border-radius: 8px; width: 26px; height: 26px; cursor: pointer; font-size: 11px; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s; }
.ico-btn:hover { background: var(--brand); color: #fff; }
.team-badge { font-size: 9px; background: #ede9fe; color: #6d28d9; border-radius: 5px; padding: 1.5px 6px; white-space: nowrap; flex-shrink: 0; font-weight: 700; }
.totp-btn { background: #e0f2fe; color: #0369a1; font-weight: 700; width: auto; padding: 0 8px; }
.totp-btn:hover { background: var(--brand2); color: #fff; }
.totp-row { display: none; padding: 6px 12px 8px 52px; }
.totp-display { display: flex; align-items: center; gap: 9px; background: #0f172a; border-radius: 10px; padding: 8px 11px; }
.totp-code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 16px; font-weight: 700; letter-spacing: .14em; color: #fff; }
.totp-bar-wrap { flex: 1; height: 4px; background: rgba(255,255,255,.18); border-radius: 3px; overflow: hidden; }
.totp-bar-fill { height: 100%; background: #34d399; border-radius: 3px; transition: width 1s linear; }
.totp-secs { font-size: 10px; color: #94a3b8; white-space: nowrap; min-width: 22px; text-align: right; }
.totp-hint { font-size: 10px; color: var(--ok); margin-top: 4px; }
/* ── States ─────────────────────────────────────────────── */
.empty { padding: 30px 22px; text-align: center; color: var(--muted); line-height: 1.7; font-size: 12px; }
.empty-emoji { font-size: 30px; display: block; margin-bottom: 8px; opacity: .7; }
.error { padding: 14px; text-align: center; color: var(--danger); font-size: 11px; line-height: 1.6; }
/* ── New Entry Panel ────────────────────────────────────── */
#newEntryPanel { display: none; background: var(--card); }
.panel-hd { background: linear-gradient(135deg,#4f46e5,#3c8dbc); color: #fff; padding: 11px 14px; display: flex; align-items: center; }
.panel-hd-title { flex: 1; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.panel-close { background: none; border: none; color: rgba(255,255,255,.8); font-size: 17px; cursor: pointer; padding: 0 2px; line-height: 1; }
.panel-close:hover { color: #fff; }
.panel-body { padding: 14px; display: flex; flex-direction: column; gap: 9px; }
.panel-input { width: 100%; padding: 9px 11px; border: 1.5px solid var(--line); border-radius: 9px; font-size: 12.5px; outline: none; font-family: inherit; transition: border-color .15s, box-shadow .15s; }
.panel-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.14); }
.panel-save { background: var(--brand); color: #fff; border: none; border-radius: 9px; padding: 10px 14px; font-size: 12.5px; font-weight: 700; cursor: pointer; width: 100%; transition: background .12s; }
.panel-save:hover { background: #4338ca; }
.panel-save:disabled { background: #aeb4c4; cursor: default; }
.panel-msg { font-size: 11px; color: var(--danger); min-height: 14px; }
/* ── Detail Panel ───────────────────────────────────────── */
#detailPanel { display: none; background: var(--bg); }
.panel-back { background: none; border: none; color: rgba(255,255,255,.85); cursor: pointer; padding: 0 6px 0 0; display: flex; align-items: center; line-height: 1; }
.panel-back:hover { color: #fff; }
.detail-body { padding: 14px; }
.detail-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.detail-icon { width: 44px; height: 44px; border-radius: 12px; background: #eef0f7; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; flex-shrink: 0; overflow: hidden; }
.detail-icon img { width: 28px; height: 28px; object-fit: contain; }
.detail-headinfo { min-width: 0; flex: 1; }
.detail-name { font-weight: 700; font-size: 15px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.detail-url-link { color: var(--brand); font-size: 11px; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.detail-url-link:hover { text-decoration: underline; }
.field { background: var(--card); border: 1.5px solid var(--line); border-radius: 11px; padding: 8px 11px; margin-bottom: 9px; }
.field-lbl { font-size: 9.5px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 3px; }
.field-row { display: flex; align-items: center; gap: 6px; }
.field-val { flex: 1; min-width: 0; font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field-val.mono { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing: .02em; }
.field-val.empty { color: #aab2c3; font-style: italic; }
.field-btn { background: #f3f4f8; border: none; border-radius: 8px; width: 30px; height: 30px; flex-shrink: 0; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s; }
.field-btn:hover { background: var(--brand); color: #fff; }
.field-totp-code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 16px; font-weight: 700; letter-spacing: .12em; color: var(--ink); }
.field-totp-secs { font-size: 10px; color: var(--muted); min-width: 24px; text-align: right; }
.detail-fill { margin-top: 4px; width: 100%; padding: 11px; background: var(--brand); color: #fff; border: none; border-radius: 11px; font-size: 13px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px; transition: background .12s; }
.detail-fill:hover { background: #4338ca; }
.detail-fill:disabled { background: #aeb4c4; cursor: default; }
/* ── New-Entry: Passwort-Zeile + Generator ──────────────── */
.ne-pass-row { display: flex; gap: 6px; align-items: center; }
.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; }
.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 ────────────────────────────────────────── */
#lockScreen { display: none; background: var(--bg); }
.lock-body { padding: 34px 24px 30px; display: flex; flex-direction: column; align-items: center; text-align: center; }
.lock-icon { width: 64px; height: 64px; border-radius: 18px; background: linear-gradient(135deg,#4f46e5,#3c8dbc); color: #fff; display: flex; align-items: center; justify-content: center; margin-bottom: 14px; box-shadow: 0 8px 22px rgba(79,70,229,.3); }
.lock-title { font-size: 16px; font-weight: 700; color: var(--ink); }
.lock-sub { font-size: 12px; color: var(--muted); margin: 4px 0 18px; }
.lock-input { width: 100%; padding: 11px 13px; border: 1.5px solid var(--line); border-radius: 10px; font-size: 16px; text-align: center; letter-spacing: .25em; outline: none; background: var(--card); color: var(--ink); transition: border-color .15s, box-shadow .15s; }
.lock-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(79,70,229,.14); }
.lock-btn { width: 100%; margin-top: 11px; padding: 11px; background: var(--brand); color: #fff; border: none; border-radius: 10px; font-size: 13px; font-weight: 700; cursor: pointer; transition: background .12s; }
.lock-btn:hover { background: #4338ca; }
.lock-btn:disabled { background: #aeb4c4; cursor: default; }
.lock-msg { font-size: 11.5px; color: var(--danger); min-height: 15px; margin-top: 9px; }
/* ── Footer ─────────────────────────────────────────────── */
.ft { padding: 9px 12px; border-top: 1px solid var(--line); background: var(--card); display: flex; gap: 7px; }
.ft-btn {
flex: 1; padding: 9px 0; background: var(--bg); border: 1.5px solid var(--line); border-radius: 10px;
cursor: pointer; color: #5b6478; text-decoration: none;
display: flex; align-items: center; justify-content: center; transition: all .12s;
}
.ft-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; transform: translateY(-1px); }
.ft-btn svg { display: block; }
/* ── Toast ──────────────────────────────────────────────── */
.toast { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); background: #0f172a; color: #fff; padding: 8px 16px; border-radius: 9px; font-size: 11.5px; opacity: 0; transition: opacity .2s; pointer-events: none; white-space: nowrap; box-shadow: 0 6px 20px rgba(0,0,0,.25); }
.toast.show { opacity: 1; }
/* ── Spinner ────────────────────────────────────────────── */
.spin { display: inline-block; width: 18px; height: 18px; border: 2.5px solid var(--line); border-top-color: var(--brand); border-radius: 50%; animation: sp .7s linear infinite; margin: auto; }
@keyframes sp { to { transform: rotate(360deg); } }
.loading { padding: 30px; display: flex; justify-content: center; }
</style>
</head>
<body>
<!-- Header -->
<div class="hd">
<div class="hd-icon"><img src="icon32.png" alt=""></div>
<div class="hd-main">
<div class="hd-title" id="hdTitle">OpenNIT Vault</div>
<div class="hd-user" id="hdUser"></div>
</div>
<div class="hd-dot" title="Verbunden"></div>
</div>
<!-- Search -->
<div class="search-wrap">
<input class="search-input" id="search" type="text" placeholder="Einträge suchen…" autocomplete="off">
</div>
<!-- Entry list (JS-controlled) -->
<div id="listWrap"><div class="loading"><div class="spin"></div></div></div>
<!-- New Entry Panel (initially hidden) -->
<div id="newEntryPanel">
<div class="panel-hd">
<span class="panel-hd-title">Neuen Eintrag anlegen</span>
<button class="panel-close" id="btnCloseNew" title="Schließen">&#x2715;</button>
</div>
<div class="panel-body">
<input class="panel-input" id="neTitle" type="text" placeholder="Titel *" autocomplete="off">
<input class="panel-input" id="neUsername" type="text" placeholder="Benutzername" autocomplete="off">
<div class="ne-pass-row">
<input class="panel-input" id="nePassword" type="password" placeholder="Passwort" autocomplete="new-password">
<button class="ne-icon-btn" id="btnRevealNewPw" type="button" title="Anzeigen/Verbergen">
<svg width="15" height="15" 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="ne-icon-btn" id="btnGenPw" type="button" title="Sicheres Passwort generieren">
<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>
<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>
<div class="panel-msg" id="newEntryMsg"></div>
</div>
</div>
<!-- Detail Panel (initially hidden) -->
<div id="detailPanel">
<div class="panel-hd">
<button class="panel-back" id="btnDetailBack" title="Zur&uuml;ck">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span class="panel-hd-title" id="detailHdTitle">Eintrag</span>
<button class="panel-close" id="btnDetailClose" title="Schlie&szlig;en">&#x2715;</button>
</div>
<div class="detail-body">
<div class="detail-head">
<div class="detail-icon" id="detailIcon"></div>
<div class="detail-headinfo">
<div class="detail-name" id="detailName"></div>
<a class="detail-url-link" id="detailUrlLink" target="_blank" rel="noopener"></a>
</div>
</div>
<div class="field" id="fieldUser">
<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>
</div>
</div>
<div class="field" id="fieldPass">
<div class="field-lbl">Passwort</div>
<div class="field-row">
<span class="field-val mono" id="detailPass">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</span>
<button class="field-btn" id="btnRevealPass" 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="btnCopyPass" 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>
</div>
</div>
<div class="field" id="fieldTotp" style="display:none;">
<div class="field-lbl">2FA-Code (TOTP)</div>
<div class="field-row">
<span class="field-totp-code" id="detailTotp">&mdash;</span>
<span class="totp-bar-wrap" style="max-width:90px;"><span class="totp-bar-fill" id="detailTotpBar"></span></span>
<span class="field-totp-secs" id="detailTotpSecs"></span>
<button class="field-btn" id="btnCopyTotp" 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>
</div>
</div>
<div class="field" id="fieldNotes" style="display:none;">
<div class="field-row" style="align-items:flex-start;">
<div style="flex:1;min-width:0;">
<div class="field-lbl">Notizen</div>
<div class="field-notes" id="detailNotes"></div>
</div>
<button class="field-btn" id="btnCopyNotes" 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>
</div>
</div>
<button class="detail-fill" id="btnDetailFill">
<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&uuml;llen
</button>
</div>
</div>
<!-- Lock Screen (initially hidden) -->
<div id="lockScreen">
<div class="lock-body">
<div class="lock-icon">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<div class="lock-title">Tresor gesperrt</div>
<div class="lock-sub">Mit deinem Tresor-PIN entsperren</div>
<input class="lock-input" id="lockPin" type="password" inputmode="numeric" placeholder="PIN" autocomplete="off">
<button class="lock-btn" id="lockSubmit">Entsperren</button>
<div class="lock-msg" id="lockMsg"></div>
</div>
</div>
<!-- Footer -->
<div class="ft">
<!-- New Entry -->
<button class="ft-btn" id="btnNew" title="Neuen Eintrag anlegen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<!-- Lock now (only shown when a PIN lock is active) -->
<button class="ft-btn" id="btnLock" title="Jetzt sperren" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</button>
<!-- Settings -->
<button class="ft-btn" id="btnOptions" title="Einstellungen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<!-- Open Vault -->
<a class="ft-btn" id="btnOpen" target="_blank" title="Vault im Browser öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
</div>
<div class="toast" id="toast"></div>
<script src="popup.js"></script>
</body>
</html>

602
extension/popup.js Normal file
View file

@ -0,0 +1,602 @@
'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">&#9888; Nicht verbunden.<br>Einstellungen pr&uuml;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&uuml;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&auml;ge (' + allEntries.length + ')</div>' +
'<div class="entries scrollable" id="eList">' +
(allEntries.length ? allEntries.map(e => entryHtml(e)).join('') : '<div class="empty">Noch keine Eintr&auml;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&auml;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';
}
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 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());
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('Eintrag gespeichert');
} else {
$('newEntryMsg').textContent = data.error || 'Fehler beim Speichern.';
}
} catch (e) {
$('newEntryMsg').textContent = 'Verbindungsfehler: ' + e.message;
}
$('btnSaveNew').disabled = false;
$('btnSaveNew').textContent = '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');
}
function loadDetailTotp(id) {
const codeEl = $('detailTotp');
const secsEl = $('detailTotpSecs');
const barEl = $('detailTotpBar');
codeEl.textContent = '…';
codeEl.dataset.code = '';
secsEl.textContent = '';
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => {
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(() => {
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);
});
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAttr(s) { return String(s||'').replace(/"/g,'&quot;'); }
init();