/**
* 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) {
const err = new Error(response?.error || "Unknown error");
err.status = response?.status;
reject(err);
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");
// UI language: the backend-decided locale (cached by the background) picks
// the dictionary from the design pack; unknown locale falls back to English.
// popup.js is a module, so the top-level await completes before the drawer
// below is created or any UI renders.
async function loadStrings() {
try {
const uiLocale = String(
(await chrome.storage.local.get("uiLocale")).uiLocale || "",
).trim().toLowerCase();
const stored = (await chrome.storage.local.get("designPack")).designPack;
const pack = window.GnPack
? GnPack.validateStoredPack(stored, chrome.runtime.getManifest().version)
: null;
const config = pack?.texts?.["config.json"]
? GnPack.resolveConfig(JSON.parse(pack.texts["config.json"]))
: GnPack.DEFAULT_CONFIG;
return { uiLocale, strings: GnPack.resolveLocalized(config, uiLocale) };
} catch {
return { uiLocale: "", strings: GnPack.resolveLocalized(GnPack.DEFAULT_CONFIG, "") };
}
}
const applyStaticStrings = (loc) => {
const byId = (id) => document.getElementById(id);
if (byId("refresh")) byId("refresh").title = loc.refresh;
if (byId("open-site")) byId("open-site").title = loc.openAppTitle;
if (byId("open-settings")) byId("open-settings").title = loc.settingsTitle;
if (byId("search")) byId("search").placeholder = loc.searchPlaceholder;
if (document.querySelector("#empty-state .empty-state-text")) {
document.querySelector("#empty-state .empty-state-text").textContent = loc.noSecrets;
}
};
const { uiLocale, strings: loc } = await loadStrings();
document.documentElement.lang = uiLocale || "en";
applyStaticStrings(loc);
const settingsDrawer = Drawer.create("settings-drawer", {
title: loc.drawerTitle,
body: () => {
const div = document.createElement("div");
div.innerHTML = `
<div class="form-group">
<label class="label" for="base-url">${loc.serverUrl}
<input type="url" id="base-url" class="input w-100" value="https://creds.gnexus.space">
</label>
</div>
<div class="form-group">
<label class="label" for="api-token">${loc.apiToken}
<input type="password" id="api-token" class="input w-100" placeholder="gcr_...">
</label>
<div class="hint text-muted text-sm">${loc.tokenHint}</div>
<div id="token-id" class="hint text-muted text-sm"></div>
</div>
<div id="settings-error-wrap"></div>
<div id="settings-success-wrap"></div>
<div id="pack-status" class="hint text-muted text-sm"></div>
`;
return div;
},
actions: () => {
const saveBtn = Helper.template.createElement("button", { class: "btn btn-primary" }, loc.save);
saveBtn.id = "save-settings";
const openBtn = Helper.template.createElement("button", { class: "btn btn-secondary" }, loc.openApp);
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");
// The stored token carries its public id: gcr_{public_id16hex}_{secret}.
// Showing the id lets the user match the popup against the tokens table on
// the site (which token is this extension actually using).
async function updateTokenId() {
const el = document.getElementById("token-id");
if (!el) return;
const token = String((await chrome.storage.local.get("token")).token || "");
const publicId = token.match(/^gcr_([0-9a-f]{16})_/)?.[1] || "";
el.textContent = publicId ? `${loc.tokenId}: ${publicId}` : "";
}
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, "<")
.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 = `
<div class="card-title">${escapeHtml(loc.saveCredentials.replace("{source}", pending.source || ""))}</div>
<div class="card-content">
<p>${escapeHtml(loc.account)} <strong>${escapeHtml(username)}</strong></p>
</div>
<div class="card-footer">
<button id="pending-save-btn" class="btn btn-success with-icon"><i class="ph ph-check"></i> ${escapeHtml(loc.save)}</button>
<button id="pending-dismiss-btn" class="btn btn-secondary">${escapeHtml(loc.dismiss)}</button>
</div>
`;
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", loc.credentialsSaved);
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 = "";
});
}
// Pack CSS (fetched by the background worker into chrome.storage.local)
// overrides the bundled popup.css when present — appended after the
// stylesheets so equal-specificity rules win. Returns the active pack.
async function applyPackStyles() {
try {
if (!window.GnPack) return null;
const manifestVersion = chrome.runtime.getManifest().version;
const stored = (await chrome.storage.local.get("designPack")).designPack;
const pack = GnPack.validateStoredPack(stored, manifestVersion);
const css = pack && pack.texts["popup.css"];
if (css) {
const style = document.createElement("style");
style.dataset.gnexusCredsCss = "pack";
style.textContent = css;
document.head.appendChild(style);
}
return pack;
} catch {
return null;
}
}
// The status line lives in the Settings drawer body; query it on each open —
// the drawer body may not be in the DOM when the popup first loads.
function updatePackStatus(pack) {
const el = document.getElementById("pack-status");
if (el) el.textContent = pack ? `Design pack v${pack.packVersion}` : "";
}
async function init() {
const packStatus = await applyPackStyles();
updatePackStatus(packStatus);
try {
const settings = await sendMessage("GET_SETTINGS");
baseUrlInput.value = settings.baseUrl || "https://creds.gnexus.space";
updateTokenId();
// The background sets a 401-side flag; show the invalid-token plate
// instead of a silently stale secrets list.
const flagged = (await chrome.storage.local.get("tokenInvalid")).tokenInvalid;
if (flagged && settings.token) {
mainErrorWrap.classList.remove("d-none");
showWrapAlert(mainErrorWrap, "error", loc.tokenInvalid);
}
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 = `<div class="d-flex justify-center">${Helper.template.circleLoaderHTML()}</div>`;
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 = `${loc.error}: ${err.message}`;
// the 401 that flips the background flag is discovered right here —
// surface the dedicated plate on this same load, not only after a reload
if (err?.status === 401) {
mainErrorWrap.classList.remove("d-none");
showWrapAlert(mainErrorWrap, "error", loc.tokenInvalid);
}
} 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) => `<span class="badge badge-secondary">${escapeHtml(t)}</span>`)
.join("");
const category = secret.category
? `<span class="badge">${escapeHtml(secret.category)}</span>`
: "";
const meta = [category, tags].filter(Boolean).join("");
const item = document.createElement("div");
item.className = "list-item";
item.innerHTML = `
<div class="list-content">
<div class="list-title">${escapeHtml(secret.title)}</div>
${meta ? `<div class="list-subtitle">${meta}</div>` : ""}
</div>
<button class="btn-icon btn-open-secret" data-id="${escapeAttr(secret.id)}" title="${escapeAttr(loc.openOnSite)}">
<i class="ph ph-arrow-square-out"></i>
</button>
`;
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", loc.enterToken);
return;
}
try {
await sendMessage("VERIFY_TOKEN", { token, baseUrl });
await sendMessage("SAVE_SETTINGS", { token, baseUrl });
showWrapAlert(settingsSuccessWrap, "success", loc.settingsSaved);
tokenInput.value = "";
setTimeout(() => {
settingsDrawer.close();
init();
}, 800);
} catch (err) {
showWrapAlert(settingsErrorWrap, "error", err.message);
}
});
openSettingsBtn.addEventListener("click", async () => {
settingsDrawer.show();
// body() rebuilds on show() — set the status line after that
updatePackStatus(await applyPackStyles());
updateTokenId();
});
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();