Newer
Older
gnexus-creds / extensions / extension / src / popup / popup.js
/**
 * 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, "&amp;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

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();