First Upload
This commit is contained in:
parent
efc6fcbafa
commit
c61677d47f
28 changed files with 3921 additions and 0 deletions
274
extension/background.js
Normal file
274
extension/background.js
Normal 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
551
extension/content.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function vaultHue(s) { s = String(s || '?'); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }
|
||||
function vaultClipCopy(text) {
|
||||
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' ? ' · 2FA' : ' · 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 = '↑↓ Navigieren · Enter Auswählen · Esc Schließ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
BIN
extension/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
extension/icon16.png
Normal file
BIN
extension/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
BIN
extension/icon32.png
Normal file
BIN
extension/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
extension/icon48.png
Normal file
BIN
extension/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 KiB |
48
extension/manifest.json
Normal file
48
extension/manifest.json
Normal 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
1
extension/offscreen.html
Normal 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
17
extension/offscreen.js
Normal 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
304
extension/options.html
Normal 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 – 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 – ohne abschließ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ügen…">
|
||||
<span class="form-text">Alternative ohne SSO (z. B. Kiosk/Headless): Vault → „Extension“ → Token generieren.</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-default btn-sm" id="btnSave">Token speichern</button>
|
||||
<button class="btn btn-default btn-sm" id="btnTest">Verbindung testen</button>
|
||||
<span id="savedMsg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 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>„Extension“</strong> in der Toolbar und generieren Sie einen API-Token.</span></li>
|
||||
<li><span class="step-badge">3</span><span>Fügen Sie den Token oben in das Token-Feld ein und klicken Sie auf <strong>Speichern</strong>.</span></li>
|
||||
<li><span class="step-badge">4</span><span>Fokussieren Sie ein Login-Feld auf einer Website – Vault-Vorschläge erscheinen automatisch.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.content-wrapper -->
|
||||
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
227
extension/options.js
Normal file
227
extension/options.js
Normal 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">✓ 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">✓ Gespeichert</span>';
|
||||
setTimeout(() => { $('savedMsg').textContent = ''; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
$('btnTest').addEventListener('click', async () => {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const token = $('apiToken').value.trim();
|
||||
|
||||
if (!url || !token || token.startsWith('•')) {
|
||||
showStatus('Bitte zuerst URL und Token eingeben und speichern.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
$('btnTest').innerHTML = '<span class="fa-spin-sm"></span> Teste…';
|
||||
$('btnTest').disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/api/vault/extension/status`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
showStatus(`Verbunden als <strong>${esc(data.user)}</strong>`, true);
|
||||
if (data.user) {
|
||||
$('headerUser').textContent = data.user;
|
||||
$('headerStatus').style.display = '';
|
||||
}
|
||||
} else {
|
||||
showStatus('Ungültiger Token oder Server-Fehler.', false);
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Server nicht erreichbar: ' + esc(e.message), false);
|
||||
}
|
||||
|
||||
$('btnTest').innerHTML = 'Verbindung testen';
|
||||
$('btnTest').disabled = false;
|
||||
});
|
||||
|
||||
// ── SSO-Anmeldung (OAuth 2.0 + PKCE via chrome.identity) ────────────────────
|
||||
function b64url(bytes) {
|
||||
let s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
|
||||
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">✓ ' + 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
347
extension/popup.html
Normal file
347
extension/popup.html
Normal 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">✕</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ü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ßen">✕</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">••••••••••</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">—</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ü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
602
extension/popup.js
Normal 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">⚠ Nicht verbunden.<br>Einstellungen prüfen.</div>';
|
||||
return;
|
||||
}
|
||||
indexEntries(allEntries);
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||||
const url = tabs[0]?.url;
|
||||
if (url && !url.startsWith('chrome://') && !url.startsWith('chrome-extension://')) {
|
||||
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url }, r2 => {
|
||||
pageMatches = r2?.entries || [];
|
||||
indexEntries(pageMatches);
|
||||
renderDefault();
|
||||
});
|
||||
} else {
|
||||
pageMatches = [];
|
||||
renderDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function indexEntries(list) {
|
||||
(list || []).forEach(e => { entryIndex[String(e.id)] = e; });
|
||||
}
|
||||
|
||||
function renderDefault() {
|
||||
selIndex = -1;
|
||||
if (pageMatches.length > 0) {
|
||||
$('listWrap').innerHTML =
|
||||
'<div class="section-lbl match">Passend für diese Seite</div>' +
|
||||
'<div class="entries" id="eList">' + pageMatches.map(e => entryHtml(e)).join('') + '</div>';
|
||||
} else {
|
||||
$('listWrap').innerHTML =
|
||||
'<div class="section-lbl">Alle Einträge (' + allEntries.length + ')</div>' +
|
||||
'<div class="entries scrollable" id="eList">' +
|
||||
(allEntries.length ? allEntries.map(e => entryHtml(e)).join('') : '<div class="empty">Noch keine Einträge vorhanden.</div>') +
|
||||
'</div>';
|
||||
}
|
||||
const el = document.getElementById('eList');
|
||||
if (el) attachHandlers(el);
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
selIndex = -1;
|
||||
const q = ($('search').value || '').trim().toLowerCase();
|
||||
if (!q) { renderDefault(); return; }
|
||||
if (!allEntries) return;
|
||||
|
||||
const filtered = allEntries.filter(e =>
|
||||
(e.title ||'').toLowerCase().includes(q) ||
|
||||
(e.username ||'').toLowerCase().includes(q) ||
|
||||
(e.url ||'').toLowerCase().includes(q) ||
|
||||
(e.notes ||'').toLowerCase().includes(q) ||
|
||||
(e.team_name||'').toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
$('listWrap').innerHTML =
|
||||
'<div class="section-lbl">Suche (' + filtered.length + ')</div>' +
|
||||
'<div class="entries scrollable" id="eList">' +
|
||||
(filtered.length ? filtered.map(e => entryHtml(e)).join('') : '<div class="empty">Keine Einträge gefunden.</div>') +
|
||||
'</div>';
|
||||
|
||||
const el = document.getElementById('eList');
|
||||
if (el) attachHandlers(el);
|
||||
}
|
||||
|
||||
// Tastatur-Navigation aus dem Suchfeld heraus (↑↓ wählt, Enter öffnet).
|
||||
function onListKeydown(e) {
|
||||
if (detailState || $('newEntryPanel').style.display === 'block') return;
|
||||
const items = [...document.querySelectorAll('#eList .entry')];
|
||||
if (!items.length) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(items, selIndex + 1); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(items, selIndex - 1); }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const target = selIndex >= 0 ? items[selIndex] : items[0];
|
||||
if (target) openDetail(target.dataset.id);
|
||||
}
|
||||
}
|
||||
function setSel(items, idx) {
|
||||
items.forEach(i => i.classList.remove('kbd-sel'));
|
||||
selIndex = Math.max(0, Math.min(idx, items.length - 1));
|
||||
const el = items[selIndex];
|
||||
if (el) { el.classList.add('kbd-sel'); el.scrollIntoView({ block: 'nearest' }); }
|
||||
}
|
||||
|
||||
// ── New Entry ─────────────────────────────────────────────────────────────
|
||||
function openNewPanel() {
|
||||
$('listWrap').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
$('detailPanel').style.display = 'none';
|
||||
$('newEntryPanel').style.display = 'block';
|
||||
$('neTitle').value = '';
|
||||
$('neUsername').value = '';
|
||||
$('nePassword').value = '';
|
||||
$('nePassword').type = 'password';
|
||||
$('neUrl').value = '';
|
||||
$('neNotes').value = '';
|
||||
$('newEntryMsg').textContent = '';
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||||
const url = tabs[0]?.url;
|
||||
if (url && !url.startsWith('chrome://') && !url.startsWith('chrome-extension://')) {
|
||||
$('neUrl').value = url;
|
||||
}
|
||||
});
|
||||
$('neTitle').focus();
|
||||
}
|
||||
|
||||
function closeNewPanel() {
|
||||
$('newEntryPanel').style.display = 'none';
|
||||
$('listWrap').style.display = '';
|
||||
$('search').closest('.search-wrap').style.display = '';
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
const len = 20;
|
||||
const sets = [
|
||||
'abcdefghijkmnopqrstuvwxyz',
|
||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
'23456789',
|
||||
'!@#$%^&*()-_=+[]{}',
|
||||
];
|
||||
const all = sets.join('');
|
||||
const buf = new Uint32Array(len);
|
||||
crypto.getRandomValues(buf);
|
||||
let out = [];
|
||||
// Mindestens ein Zeichen je Set
|
||||
sets.forEach((s, i) => { out.push(s[buf[i] % s.length]); });
|
||||
for (let i = sets.length; i < len; i++) out.push(all[buf[i] % all.length]);
|
||||
// mischen
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = buf[i] % (i + 1);
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
$('nePassword').value = out.join('');
|
||||
$('nePassword').type = 'text';
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAttr(s) { return String(s||'').replace(/"/g,'"'); }
|
||||
|
||||
init();
|
||||
Loading…
Add table
Add a link
Reference in a new issue