diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7231f1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build/ +dist/ +lib/ +node_modules/ +*.zip +popup-test-*.png +test-*.html +test-*.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b5266c5 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +NAME := gnexus-creds-extension +VERSION := 0.1.0 +SRC := manifest.json src icons lib +BUILD := build +DIST := dist + +.PHONY: all clean chrome firefox + +all: chrome firefox + +clean: + rm -rf $(BUILD) $(DIST) + +$(BUILD)/chrome: $(SRC) + mkdir -p $@ + cp -r $(SRC) $@/ + node -e "const fs=require('fs'); const m=JSON.parse(fs.readFileSync('$@/manifest.json')); delete m.browser_specific_settings; fs.writeFileSync('$@/manifest.json', JSON.stringify(m,null,2));" + sed -i 's|url("../assets/|url("assets/|g' $@/lib/gnexus-ui-kit.css + sed -i "s|url('/assets/|url('assets/|g" $@/lib/gnexus-ui-kit.css + +$(BUILD)/firefox: $(SRC) + mkdir -p $@ + cp -r $(SRC) $@/ + sed -i 's|url("../assets/|url("assets/|g' $@/lib/gnexus-ui-kit.css + sed -i "s|url('/assets/|url('assets/|g" $@/lib/gnexus-ui-kit.css + +chrome: $(BUILD)/chrome + mkdir -p $(DIST) + cd $(BUILD)/chrome && zip -r ../../$(DIST)/$(NAME)-chrome-$(VERSION).zip . + +firefox: $(BUILD)/firefox + mkdir -p $(DIST) + cd $(BUILD)/firefox && zip -r ../../$(DIST)/$(NAME)-firefox-$(VERSION).zip . diff --git a/README.md b/README.md index b8b7df2..b25b851 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,47 @@ -gnexus-creds-extension -=============== +# gnexus-creds-extension + +Кросс-браузерное расширение для интеграции с [gnexus-creds](https://creds.gnexus.space). + +## Возможности + +- **Автозаполнение** — обнаруживает формы логина и предлагает заполнить сохранённые креды. +- **Сохранение кредов** — перехватывает введённые данные и предлагает сохранить их как секрет. +- **Popup** — быстрый доступ к списку секретов, поиск, reveal и копирование полей. + +## Установка + +### Chrome + +1. Откройте `chrome://extensions/`. +2. Включите "Developer mode". +3. Нажмите "Load unpacked" и выберите папку проекта. +4. (Или установите `.zip` из `dist/`). + +### Firefox + +1. Откройте `about:debugging#/runtime/this-firefox`. +2. Нажмите "Load Temporary Add-on" и выберите `manifest.json`. +3. (Или установите `.zip` из `dist/`). + +## Настройка + +1. Откройте popup (иконка в тулбаре). +2. Введите URL сервера (по умолчанию `https://creds.gnexus.space`). +3. Введите API-токен с правами `read`, `reveal`, `write`. +4. Нажмите "Сохранить". + +API-токен создаётся на сайте gnexus-creds в разделе "Токены". + +## Сборка + +```bash +make +``` + +Создаст архивы в `dist/`: +- `gnexus-creds-extension-chrome-0.1.0.zip` +- `gnexus-creds-extension-firefox-0.1.0.zip` + +## Лицензия + +MIT diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 0000000..afba9a6 --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..78dc2a7 --- /dev/null +++ b/icons/icon128.png Binary files differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..d2ad751 --- /dev/null +++ b/icons/icon16.png Binary files differ diff --git a/icons/icon32.png b/icons/icon32.png new file mode 100644 index 0000000..4220c4e --- /dev/null +++ b/icons/icon32.png Binary files differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..05ecf0d --- /dev/null +++ b/icons/icon48.png Binary files differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..821cb09 --- /dev/null +++ b/manifest.json @@ -0,0 +1,46 @@ +{ + "manifest_version": 3, + "name": "gnexus-creds", + "version": "0.1.0", + "description": "Browser extension for gnexus-creds secret manager.", + "permissions": ["storage", "activeTab"], + "host_permissions": ["https://*/*", "http://*/*"], + "background": { + "service_worker": "src/background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["src/content.js"], + "css": ["src/content.css"], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "web_accessible_resources": [ + { + "resources": ["lib/assets/fonts/*/*", "lib/assets/fonts/*/*/*"], + "matches": [""] + } + ], + "browser_specific_settings": { + "gecko": { + "id": "gnexus-creds@gnexus.space" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b587f7c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "gnexus-creds-extension", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright": "^1.60.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f2eab14 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "playwright": "^1.60.0" + } +} diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..48a8779 --- /dev/null +++ b/src/api.js @@ -0,0 +1,42 @@ +/** + * API client for gnexus-creds REST API. + */ +async function request(path, options = {}, token, baseUrl) { + const url = `${baseUrl}${path}`; + const headers = { + Accept: "application/json", + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers || {}), + }; + + const response = await fetch(url, { ...options, headers }); + if (response.status === 204) return null; + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.error?.message || `HTTP ${response.status}`); + } + return payload; +} + +export async function listSecrets(token, baseUrl, params = {}) { + const query = new URLSearchParams({ limit: "50", ...params }); + return request(`/api/v1/secrets?${query}`, {}, token, baseUrl); +} + +export async function createSecret(token, baseUrl, payload) { + return request(`/api/v1/secrets`, { method: "POST", body: JSON.stringify(payload) }, token, baseUrl); +} + +export async function revealSecret(token, baseUrl, id) { + return request(`/api/v1/secrets/${id}/reveal`, { method: "POST" }, token, baseUrl); +} + +export async function getMe(token, baseUrl) { + return request(`/api/v1/me`, {}, token, baseUrl); +} + +export async function getCategories(token, baseUrl) { + return request(`/api/v1/categories`, {}, token, baseUrl); +} diff --git a/src/background.js b/src/background.js new file mode 100644 index 0000000..ff460c2 --- /dev/null +++ b/src/background.js @@ -0,0 +1,158 @@ +import { listSecrets, revealSecret, createSecret, getMe, getCategories } from "./api.js"; + +const CACHE_TTL_MS = 5 * 60 * 1000; + +async function getSettings() { + return chrome.storage.local.get({ token: "", baseUrl: "https://creds.gnexus.space" }); +} + +async function setSettings(settings) { + return chrome.storage.local.set(settings); +} + +async function getSecretsCached(force = false) { + const now = Date.now(); + if (!force) { + try { + const cached = await chrome.storage.session.get(["secretsCache", "cacheTimestamp"]); + if (cached.secretsCache && cached.cacheTimestamp && now - cached.cacheTimestamp < CACHE_TTL_MS) { + return cached.secretsCache; + } + } catch { + // session storage may be unavailable in some contexts + } + } + const { token, baseUrl } = await getSettings(); + if (!token) throw new Error("No API token configured"); + const data = await listSecrets(token, baseUrl); + try { + await chrome.storage.session.set({ secretsCache: data, cacheTimestamp: now }); + } catch { + // ignore session storage errors + } + return data; +} + +async function invalidateCache() { + try { + await chrome.storage.session.remove(["secretsCache", "cacheTimestamp"]); + } catch { + // ignore + } +} + +function secretsForDomain(secrets, domain) { + if (!secrets?.items) return []; + return secrets.items.filter((s) => { + const src = (s.source || "").toLowerCase(); + const title = (s.title || "").toLowerCase(); + const d = domain.toLowerCase(); + return src.includes(d) || title.includes(d); + }); +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + (async () => { + try { + const { token, baseUrl } = await getSettings(); + switch (message.type) { + case "GET_SETTINGS": + sendResponse({ ok: true, data: await getSettings() }); + break; + case "SAVE_SETTINGS": + await setSettings(message.payload); + invalidateCache(); + sendResponse({ ok: true }); + break; + case "VERIFY_TOKEN": + { + const me = await getMe(message.payload.token, message.payload.baseUrl); + sendResponse({ ok: true, data: me }); + } + break; + case "LIST_SECRETS": + { + const data = await getSecretsCached(message.payload?.force); + sendResponse({ ok: true, data }); + } + break; + case "SEARCH_SECRETS": + { + const data = await listSecrets(token, baseUrl, { q: message.payload.q }); + sendResponse({ ok: true, data }); + } + break; + case "REVEAL_SECRET": + { + const data = await revealSecret(token, baseUrl, message.payload.id); + sendResponse({ ok: true, data }); + } + break; + case "CREATE_SECRET": + { + const data = await createSecret(token, baseUrl, message.payload); + invalidateCache(); + try { + await chrome.storage.session.remove("pendingSave"); + await chrome.action.setBadgeText({ text: "" }); + } catch { + // ignore + } + sendResponse({ ok: true, data }); + } + break; + case "GET_SECRETS_FOR_DOMAIN": + { + const all = await getSecretsCached(); + const matched = secretsForDomain(all, message.payload.domain); + sendResponse({ ok: true, data: matched }); + } + break; + case "GET_CATEGORIES": + { + const data = await getCategories(token, baseUrl); + sendResponse({ ok: true, data }); + } + break; + case "PENDING_SAVE": + { + const pending = { + ...message.payload, + timestamp: Date.now(), + }; + await chrome.storage.session.set({ pendingSave: pending }); + try { + await chrome.action.setBadgeText({ text: "1" }); + await chrome.action.setBadgeBackgroundColor({ color: "#238636" }); + } catch { + // ignore + } + sendResponse({ ok: true }); + } + break; + case "GET_PENDING_SAVE": + { + const stored = await chrome.storage.session.get("pendingSave"); + sendResponse({ ok: true, data: stored.pendingSave || null }); + } + break; + case "CLEAR_PENDING_SAVE": + { + await chrome.storage.session.remove("pendingSave"); + try { + await chrome.action.setBadgeText({ text: "" }); + } catch { + // ignore + } + sendResponse({ ok: true }); + } + break; + default: + sendResponse({ ok: false, error: "Unknown message type" }); + } + } catch (err) { + sendResponse({ ok: false, error: err.message }); + } + })(); + return true; +}); diff --git a/src/content.css b/src/content.css new file mode 100644 index 0000000..87b74b7 --- /dev/null +++ b/src/content.css @@ -0,0 +1,78 @@ +.gnexus-creds-autofill-container { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 2147483647; + display: flex; + flex-direction: column; + gap: 8px; + width: max-content; + max-width: calc(100% - 32px); + pointer-events: none; +} + +.gnexus-creds-autofill-card { + pointer-events: auto; + background: #161b22; + border: 1px solid #30363d; + border-top: 2px solid #58a6ff; + color: #c9d1d9; + font-family: 'IBM Plex Mono', ui-monospace, monospace; + font-size: 12px; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + padding: 12px 16px; + min-width: 260px; + max-width: 400px; + width: 100%; + opacity: 0; + transform: translateY(-12px); + transition: opacity 0.3s ease-out, transform 0.3s ease-out; +} + +.gnexus-creds-autofill-card-visible { + opacity: 1; + transform: translateY(0); +} + +.gnexus-creds-autofill-card-exit { + opacity: 0; + transform: translateY(-12px); + transition: opacity 0.25s ease-in, transform 0.25s ease-in; +} + +.gnexus-creds-autofill-title { + font-weight: 600; + color: #58a6ff; + word-break: break-word; + margin-bottom: 10px; +} + +.gnexus-creds-autofill-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.gnexus-creds-autofill-btn { + background: #238636; + border: none; + color: #fff; + padding: 5px 12px; + border-radius: 4px; + font-family: inherit; + font-size: 11px; + cursor: pointer; + line-height: 1; +} + +.gnexus-creds-autofill-btn-secondary { + background: transparent; + border: 1px solid #30363d; + color: #7d8590; +} + +.gnexus-creds-autofill-btn:hover { + opacity: 0.85; +} diff --git a/src/content.js b/src/content.js new file mode 100644 index 0000000..47b95a4 --- /dev/null +++ b/src/content.js @@ -0,0 +1,313 @@ +/** + * Content script for login form detection, autofill, and save prompt. + */ + +let secretsForDomain = []; +let shownCards = new WeakSet(); +let autofillContainer = null; + +// --- Messaging helper --- + +function send(type, payload) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type, payload }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response || !response.ok) { + reject(new Error(response?.error || "Unknown error")); + return; + } + resolve(response.data); + }); + }); +} + +// --- Debounce helper --- + +function debounce(fn, ms) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; +} + +// --- Domain matching --- + +async function fetchSecretsForDomain() { + try { + const domain = location.hostname; + secretsForDomain = await send("GET_SECRETS_FOR_DOMAIN", { domain }); + } catch { + secretsForDomain = []; + } +} + +// --- Form detection --- + +function findLoginForms() { + const forms = []; + const inputs = document.querySelectorAll('input[type="password"]'); + for (const passwordInput of inputs) { + if (passwordInput.dataset.gnexusCreds === "handled") continue; + passwordInput.dataset.gnexusCreds = "handled"; + + const { form, usernameInput } = findFormContext(passwordInput); + forms.push({ form, usernameInput, passwordInput }); + } + return forms; +} + +function findFormContext(passwordInput) { + const form = passwordInput.closest("form"); + if (form) { + return { form, usernameInput: findUsernameInput(form, passwordInput) }; + } + // Fallback for SPA inputs without a
tag: + // look within a parent container (section/article/div) up to 3 levels up + let container = passwordInput.parentElement; + let depth = 0; + while (container && depth < 3) { + const user = findUsernameInput(container, passwordInput); + if (user) return { form: container, usernameInput: user }; + container = container.parentElement; + depth++; + } + return { form: null, usernameInput: null }; +} + +function findUsernameInput(scope, passwordInput) { + const candidates = scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])'); + for (const input of candidates) { + if (input === passwordInput) continue; + const name = (input.name || "").toLowerCase(); + const id = (input.id || "").toLowerCase(); + const placeholder = (input.placeholder || "").toLowerCase(); + const autocomp = (input.autocomplete || "").toLowerCase(); + if ( + autocomp.includes("username") || + autocomp.includes("email") || + name.includes("user") || + name.includes("login") || + name.includes("email") || + id.includes("user") || + id.includes("login") || + id.includes("email") || + placeholder.includes("user") || + placeholder.includes("login") || + placeholder.includes("email") + ) { + return input; + } + } + // Fallback: first text/email input before the password + const all = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])')); + const idx = all.indexOf(passwordInput); + if (idx > 0) return all[idx - 1]; + return all[0] || null; +} + +// --- Autofill card --- + +function getAutofillContainer() { + if (autofillContainer) return autofillContainer; + autofillContainer = document.createElement("div"); + autofillContainer.className = "gnexus-creds-autofill-container"; + document.body.appendChild(autofillContainer); + return autofillContainer; +} + +function removeAutofillCard(card, key) { + card.classList.remove("gnexus-creds-autofill-card-visible"); + card.classList.add("gnexus-creds-autofill-card-exit"); + setTimeout(() => { + card.remove(); + shownCards.delete(key); + if (autofillContainer && !autofillContainer.children.length) { + autofillContainer.remove(); + autofillContainer = null; + } + }, 280); +} + +function showAutofillCard(form, usernameInput, passwordInput, secret) { + const key = form || passwordInput; + if (!key || shownCards.has(key)) return; + shownCards.add(key); + + const container = getAutofillContainer(); + const card = document.createElement("div"); + card.className = "gnexus-creds-autofill-card"; + card.innerHTML = ` +
${escapeHtml(secret.title)}
+
+ + +
+ `; + + container.appendChild(card); + requestAnimationFrame(() => { + card.classList.add("gnexus-creds-autofill-card-visible"); + }); + + card.querySelector('[data-action="use"]').addEventListener("click", async () => { + try { + const revealed = await send("REVEAL_SECRET", { id: secret.id }); + const fields = revealed.fields || []; + const userField = fields.find((f) => /user|login|email/i.test(f.name)); + const passField = fields.find((f) => /pass|password|пароль/i.test(f.name)); + if (userField && usernameInput) { + usernameInput.value = userField.value || ""; + triggerInputEvents(usernameInput); + } + if (passField && passwordInput) { + passwordInput.value = passField.value || ""; + triggerInputEvents(passwordInput); + } + } catch (err) { + console.error("[gnexus-creds] autofill failed:", err.message); + } + removeAutofillCard(card, key); + }); + + card.querySelector('[data-action="dismiss"]').addEventListener("click", () => { + removeAutofillCard(card, key); + }); +} + +function triggerInputEvents(input) { + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +// --- Save prompt (persistent via background) --- + +function isAlreadySaved(domain, username) { + if (!secretsForDomain.length) return false; + if (username) { + return secretsForDomain.some((s) => (s.title || "").includes(username)); + } + return secretsForDomain.some((s) => (s.source || "").includes(domain) || (s.title || "").includes(domain)); +} + +function notifyPendingSave(username, password) { + const domain = location.hostname; + if (isAlreadySaved(domain, username)) return; + const title = `${domain} — ${username || "account"}`; + chrome.runtime.sendMessage( + { + type: "PENDING_SAVE", + payload: { + title, + source: domain, + fields: [ + { name: "username", value: username || "", encrypted: true, masked: false }, + { name: "password", value: password || "", encrypted: true, masked: true }, + ], + category: "web", + tags: ["autofill", "browser-extension", "credentials"], + allow_ui: true, + allow_rest_api: true, + }, + }, + () => { + // Ignore port-closed errors when the page navigates away + if (chrome.runtime.lastError) { + /* ignore */ + } + } + ); +} + +// --- Intercept form submit --- + +function attachSubmitInterceptor(form, usernameInput, passwordInput) { + if (form) { + form.addEventListener("submit", () => { + const username = usernameInput?.value?.trim() || ""; + const password = passwordInput?.value?.trim() || ""; + if (!password) return; + notifyPendingSave(username, password); + }, true); + } else { + // No tag: intercept Enter key on password input + // and clicks on nearby submit-like buttons + passwordInput.addEventListener("keydown", (e) => { + if (e.key !== "Enter") return; + const username = usernameInput?.value?.trim() || ""; + const password = passwordInput?.value?.trim() || ""; + if (!password) return; + notifyPendingSave(username, password); + }); + + // Try to find a submit button near the password field + let container = passwordInput.parentElement; + let depth = 0; + while (container && depth < 3) { + const btn = container.querySelector('button[type="submit"], input[type="submit"], button:not([type])'); + if (btn) { + btn.addEventListener("click", () => { + const username = usernameInput?.value?.trim() || ""; + const password = passwordInput?.value?.trim() || ""; + if (!password) return; + notifyPendingSave(username, password); + }); + break; + } + container = container.parentElement; + depth++; + } + } +} + +// --- Main flow --- + +async function scanPage() { + await fetchSecretsForDomain(); + const forms = findLoginForms(); + for (const { form, usernameInput, passwordInput } of forms) { + // Autofill card + if (secretsForDomain.length > 0 && usernameInput) { + const bestMatch = secretsForDomain[0]; + showAutofillCard(form, usernameInput, passwordInput, bestMatch); + } + // Save interceptor + attachSubmitInterceptor(form, usernameInput, passwordInput); + } +} + +const debouncedScan = debounce(scanPage, 300); + +// Observe DOM changes for SPA-like navigation — filter for password inputs +const observer = new MutationObserver((mutations) => { + let hasPassword = false; + for (const m of mutations) { + for (const node of m.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (node.matches?.('input[type="password"]') || node.querySelector?.('input[type="password"]')) { + hasPassword = true; + break; + } + } + if (hasPassword) break; + } + if (hasPassword) debouncedScan(); +}); +observer.observe(document.documentElement, { childList: true, subtree: true }); + +// Initial scan +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", scanPage); +} else { + scanPage(); +} + +function escapeHtml(text) { + if (text == null) return ""; + const div = document.createElement("div"); + div.textContent = String(text); + return div.innerHTML; +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..ecb886d --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,22 @@ +body { + width: 420px; + min-height: 500px; +} + +.page-header .page-header-content { + display: flex; + flex-direction: row; + align-items: center; +} + +.page-header-logo { + width: 28px; + height: 28px; + margin-right: 8px; +} + +.list-subtitle { + display: flex; + flex-wrap: wrap; + gap: 4px; +} diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..cbbf274 --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,54 @@ + + + + + + gnexus-creds + + + + + + +
+ + + +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+

No secrets

+
+ +
+ + + diff --git a/src/popup/popup.js b/src/popup/popup.js new file mode 100644 index 0000000..bc737f3 --- /dev/null +++ b/src/popup/popup.js @@ -0,0 +1,305 @@ +/** + * Popup logic for gnexus-creds extension. + * Uses GNexusUIKit as a dependency. + */ + +const { Helper, Drawer } = window.GNexusUIKit; + +async function sendMessage(type, payload) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type, payload }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response || !response.ok) { + reject(new Error(response?.error || "Unknown error")); + return; + } + resolve(response.data); + }); + }); +} + +const searchInput = document.getElementById("search"); +const secretsList = document.getElementById("secrets-list"); +const emptyState = document.getElementById("empty-state"); +const loaderWrap = document.getElementById("loader-wrap"); +const mainErrorWrap = document.getElementById("main-error-wrap"); +const pendingSaveWrap = document.getElementById("pending-save-wrap"); +const refreshBtn = document.getElementById("refresh"); +const openSiteBtn = document.getElementById("open-site"); +const openSettingsBtn = document.getElementById("open-settings"); + +const settingsDrawer = Drawer.create("settings-drawer", { + title: "Settings", + body: () => { + const div = document.createElement("div"); + div.innerHTML = ` +
+ +
+
+ +
+ Create a token with read, reveal, write scopes on the website. +
+
+
+
+ `; + return div; + }, + actions: () => { + const saveBtn = Helper.template.createElement("button", { class: "btn btn-primary" }, "Save"); + saveBtn.id = "save-settings"; + const openBtn = Helper.template.createElement("button", { class: "btn btn-secondary" }, "Open gnexus-creds"); + openBtn.id = "open-site-settings"; + return [saveBtn, openBtn]; + }, +}); + +const baseUrlInput = settingsDrawer.querySelector("#base-url"); +const tokenInput = settingsDrawer.querySelector("#api-token"); +const saveBtn = settingsDrawer.querySelector("#save-settings"); +const openSiteSettingsBtn = settingsDrawer.querySelector("#open-site-settings"); +const settingsErrorWrap = settingsDrawer.querySelector("#settings-error-wrap"); +const settingsSuccessWrap = settingsDrawer.querySelector("#settings-success-wrap"); + +function escapeHtml(text) { + if (text == null) return ""; + const div = document.createElement("div"); + div.textContent = String(text); + return div.innerHTML; +} + +function escapeAttr(text) { + if (text == null) return ""; + return String(text) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +function showWrapAlert(wrap, type, text) { + wrap.innerHTML = ""; + const el = Helper.template.createAlert(type, text); + wrap.appendChild(el); +} + +function clearWrap(wrap) { + wrap.innerHTML = ""; +} + +function renderPendingSave(pending) { + pendingSaveWrap.classList.remove("d-none"); + pendingSaveWrap.innerHTML = ""; + + const card = document.createElement("div"); + card.className = "card status-card card-info"; + card.style.width = "100%"; + card.style.maxWidth = "100%"; + + const usernameField = pending.fields?.find((f) => f.name === "username"); + const username = usernameField?.value || ""; + + card.innerHTML = ` +
Save credentials for ${escapeHtml(pending.source)}?
+
+

Account: ${escapeHtml(username)}

+
+ + `; + + pendingSaveWrap.appendChild(card); + + card.querySelector("#pending-save-btn").addEventListener("click", async () => { + clearWrap(mainErrorWrap); + try { + const payload = { + title: pending.title, + source: pending.source, + fields: pending.fields, + category: pending.category, + tags: pending.tags, + allow_ui: pending.allow_ui, + allow_rest_api: pending.allow_rest_api, + }; + await sendMessage("CREATE_SECRET", payload); + pendingSaveWrap.classList.add("d-none"); + pendingSaveWrap.innerHTML = ""; + mainErrorWrap.classList.remove("d-none"); + showWrapAlert(mainErrorWrap, "success", "Credentials saved"); + loadSecrets(); + } catch (err) { + mainErrorWrap.classList.remove("d-none"); + showWrapAlert(mainErrorWrap, "error", err.message); + } + }); + + card.querySelector("#pending-dismiss-btn").addEventListener("click", async () => { + try { + await sendMessage("CLEAR_PENDING_SAVE"); + } catch { + // ignore + } + pendingSaveWrap.classList.add("d-none"); + pendingSaveWrap.innerHTML = ""; + }); +} + +async function init() { + try { + const settings = await sendMessage("GET_SETTINGS"); + baseUrlInput.value = settings.baseUrl || "https://creds.gnexus.space"; + + const pending = await sendMessage("GET_PENDING_SAVE"); + if (pending) { + if (!settings.token) { + settingsDrawer.show(); + return; + } + renderPendingSave(pending); + await loadSecrets(); + return; + } + + if (!settings.token) { + if (settings.baseUrl) baseUrlInput.value = settings.baseUrl; + settingsDrawer.show(); + return; + } + await loadSecrets(); + } catch (err) { + settingsDrawer.show(); + mainErrorWrap.classList.remove("d-none"); + showWrapAlert(mainErrorWrap, "error", err.message); + } +} + +async function loadSecrets(query = "", force = false) { + try { + loaderWrap.classList.remove("d-none"); + loaderWrap.innerHTML = `
${Helper.template.circleLoaderHTML()}
`; + secretsList.innerHTML = ""; + emptyState.classList.add("d-none"); + mainErrorWrap.classList.add("d-none"); + clearWrap(mainErrorWrap); + + const data = query + ? await sendMessage("SEARCH_SECRETS", { q: query }) + : await sendMessage("LIST_SECRETS", { force }); + renderSecrets(data?.items || []); + } catch (err) { + secretsList.innerHTML = ""; + emptyState.classList.remove("d-none"); + emptyState.querySelector(".empty-state-text").textContent = `Error: ${err.message}`; + } finally { + loaderWrap.classList.add("d-none"); + } +} + +function renderSecrets(secrets) { + secretsList.innerHTML = ""; + if (!secrets.length) { + emptyState.classList.remove("d-none"); + return; + } + emptyState.classList.add("d-none"); + + const list = document.createElement("div"); + list.className = "list list-actions w-100"; + + for (const secret of secrets) { + const tags = (secret.tags || []) + .map((t) => `${escapeHtml(t)}`) + .join(""); + const category = secret.category + ? `${escapeHtml(secret.category)}` + : ""; + const meta = [category, tags].filter(Boolean).join(""); + + const item = document.createElement("div"); + item.className = "list-item"; + item.innerHTML = ` +
+
${escapeHtml(secret.title)}
+ ${meta ? `
${meta}
` : ""} +
+ + `; + list.appendChild(item); + } + + secretsList.appendChild(list); +} + +saveBtn.addEventListener("click", async () => { + clearWrap(settingsErrorWrap); + clearWrap(settingsSuccessWrap); + + const baseUrl = baseUrlInput.value.trim() || "https://creds.gnexus.space"; + const token = tokenInput.value.trim(); + + if (!token) { + showWrapAlert(settingsErrorWrap, "error", "Enter API token"); + return; + } + + try { + await sendMessage("VERIFY_TOKEN", { token, baseUrl }); + await sendMessage("SAVE_SETTINGS", { token, baseUrl }); + showWrapAlert(settingsSuccessWrap, "success", "Settings saved"); + tokenInput.value = ""; + setTimeout(() => { + settingsDrawer.close(); + init(); + }, 800); + } catch (err) { + showWrapAlert(settingsErrorWrap, "error", err.message); + } +}); + +openSettingsBtn.addEventListener("click", () => settingsDrawer.show()); + +openSiteBtn.addEventListener("click", () => { + const base = baseUrlInput.value.trim() || "https://creds.gnexus.space"; + chrome.tabs.create({ url: base }); +}); + +if (openSiteSettingsBtn) { + openSiteSettingsBtn.addEventListener("click", () => { + const base = baseUrlInput.value.trim() || "https://creds.gnexus.space"; + chrome.tabs.create({ url: base }); + }); +} + +secretsList.addEventListener("click", (e) => { + const btn = e.target.closest(".btn-open-secret"); + if (!btn) return; + const id = btn.dataset.id; + const base = baseUrlInput.value.trim() || "https://creds.gnexus.space"; + const url = base.replace(/\/$/, "") + "/secret/" + id; + chrome.tabs.create({ url }); +}); + +refreshBtn.addEventListener("click", () => loadSecrets(searchInput.value.trim(), true)); + +let searchDebounce; +searchInput.addEventListener("input", (e) => { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => loadSecrets(e.target.value.trim()), 300); +}); + +init();