Newer
Older
gnexus-creds-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) {
        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 = `
      <div class="form-group">
        <label class="label" for="base-url">Server URL
          <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">API token
          <input type="password" id="api-token" class="input w-100" placeholder="gcr_...">
        </label>
        <div class="hint text-muted text-sm">
          Create a token with <code>read</code>, <code>reveal</code>, <code>write</code> scopes on the website.
        </div>
      </div>
      <div id="settings-error-wrap"></div>
      <div id="settings-success-wrap"></div>
    `;
    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, "&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">Save credentials for ${escapeHtml(pending.source)}?</div>
    <div class="card-content">
      <p>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> Save</button>
      <button id="pending-dismiss-btn" class="btn btn-secondary">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", "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 = `<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 = `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) => `<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="Open on gnexus-creds">
        <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", "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();