From 29df483a2703edb3cb1009dc613ec12f97fb6290 Mon Sep 17 00:00:00 2001
From: Timothy Carambat <rambat1010@gmail.com>
Date: Tue, 27 Aug 2024 14:58:47 -0700
Subject: [PATCH] AnythingLLM Chrome Extension (#2066)

* initial commit for chrome extension

* wip browser extension backend

* wip frontend browser extension settings

* fix typo for browserExtension route

* implement verification codes + frontend panel for browser extension keys

* reorganize + state management for all connection states

* implement embed to workspace

* add send page to anythingllm extension option + refactor

* refactor connection string auth + update context menus + organize background.js into models

* popup extension from main app and save if successful

* fix hebrew translation misspelling

* fetch custom logo inside chrome extension

* delete api keys on disconnect of extension

* use correct apiUrl constant in frontend + remove unneeded comments

* remove upload-link endpoint and send inner text html to raw text collector endpoint

* update readme

* fix readme link

* fix readme typo

* update readme

* handle deletion of browser keys with key id and DELETE endpoint

* move event string to constant

* remove tablename and writable fields from BrowserExtensionApiKey backend model

* add border-none to all buttons and inputs for desktop compatibility

* patch prisma injections

* update delete endpoints to delete keys by id

* remove unused prop

* add button to attempt browser extension connection + remove max active keys

* wip multi user mode support

* multi user mode support

* clean up backend + show created by in frotend browser extension page

* show multi user warning message on key creation + hide context menus when no workspaces

* show browser extension options to managers

* small backend changes and refactors

* extension cleanup

* rename submodule

* extension updates & docs

* dev docker build

---------

Co-authored-by: shatfield4 <seanhatfield5@gmail.com>
---
 .github/workflows/dev-build.yaml              |   2 +-
 .gitmodules                                   |   3 +
 README.md                                     |   3 +-
 browser-extension                             |   1 +
 embed                                         |   1 -
 frontend/src/App.jsx                          |   7 +
 .../src/components/SettingsSidebar/index.jsx  |   6 +
 frontend/src/locales/de/common.js             |   1 +
 frontend/src/locales/en/common.js             |   1 +
 frontend/src/locales/es/common.js             |   1 +
 frontend/src/locales/fr/common.js             |   1 +
 frontend/src/locales/he/common.js             |   1 +
 frontend/src/locales/it/common.js             |   1 +
 frontend/src/locales/ko/common.js             |   1 +
 frontend/src/locales/pt_BR/common.js          |   1 +
 frontend/src/locales/ru/common.js             |   1 +
 frontend/src/locales/zh/common.js             |   1 +
 frontend/src/models/browserExtensionApiKey.js |  42 ++++
 .../BrowserExtensionApiKeyRow/index.jsx       | 120 ++++++++++
 .../NewBrowserExtensionApiKeyModal/index.jsx  | 127 ++++++++++
 .../BrowserExtensionApiKey/index.jsx          | 133 +++++++++++
 frontend/src/utils/constants.js               |   2 +
 frontend/src/utils/paths.js                   |   3 +
 server/endpoints/browserExtension.js          | 224 ++++++++++++++++++
 server/endpoints/system.js                    |   2 +
 server/index.js                               |   4 +
 server/models/browserExtensionApiKey.js       | 168 +++++++++++++
 .../20240824005054_init/migration.sql         |  15 ++
 server/prisma/schema.prisma                   |  12 +
 .../middleware/validBrowserExtensionApiKey.js |  36 +++
 30 files changed, 918 insertions(+), 3 deletions(-)
 create mode 160000 browser-extension
 delete mode 160000 embed
 create mode 100644 frontend/src/models/browserExtensionApiKey.js
 create mode 100644 frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx
 create mode 100644 frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx
 create mode 100644 frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx
 create mode 100644 server/endpoints/browserExtension.js
 create mode 100644 server/models/browserExtensionApiKey.js
 create mode 100644 server/prisma/migrations/20240824005054_init/migration.sql
 create mode 100644 server/utils/middleware/validBrowserExtensionApiKey.js

diff --git a/.github/workflows/dev-build.yaml b/.github/workflows/dev-build.yaml
index aef9a6c35..6d938af8e 100644
--- a/.github/workflows/dev-build.yaml
+++ b/.github/workflows/dev-build.yaml
@@ -6,7 +6,7 @@ concurrency:
 
 on:
   push:
-    branches: ['encrypt-jwt-value'] # put your current branch to create a build. Core team only.
+    branches: ['chrome-extension'] # put your current branch to create a build. Core team only.
     paths-ignore:
       - '**.md'
       - 'cloud-deployments/*'
diff --git a/.gitmodules b/.gitmodules
index dfb4bfcaa..a27e72e97 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -2,3 +2,6 @@
 	branch = main
 	path = embed
 	url = git@github.com:Mintplex-Labs/anythingllm-embed.git
+[submodule "browser-extension"]
+	path = browser-extension
+	url = git@github.com:Mintplex-Labs/anythingllm-extension.git
diff --git a/README.md b/README.md
index 299ab9ea6..5c4e9cccb 100644
--- a/README.md
+++ b/README.md
@@ -137,7 +137,8 @@ This monorepo consists of three main sections:
 - `server`: A NodeJS express server to handle all the interactions and do all the vectorDB management and LLM interactions.
 - `collector`: NodeJS express server that process and parses documents from the UI.
 - `docker`: Docker instructions and build process + information for building from source.
-- `embed`: Submodule specifically for generation & creation of the [web embed widget](https://github.com/Mintplex-Labs/anythingllm-embed).
+- `embed`: Submodule for generation & creation of the [web embed widget](https://github.com/Mintplex-Labs/anythingllm-embed).
+- `browser-extension`: Submodule for the [chrome browser extension](https://github.com/Mintplex-Labs/anythingllm-extension).
 
 ## 🛳 Self Hosting
 
diff --git a/browser-extension b/browser-extension
new file mode 160000
index 000000000..d9b28cc1e
--- /dev/null
+++ b/browser-extension
@@ -0,0 +1 @@
+Subproject commit d9b28cc1e23b64fdb4e666d5b5b49cc8e583aabd
diff --git a/embed b/embed
deleted file mode 160000
index 22a0848d5..000000000
--- a/embed
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 22a0848d58e3a758d85d93d9204a72a65854ea94
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 3737541f2..c6cac66db 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -49,6 +49,9 @@ const GeneralVectorDatabase = lazy(
   () => import("@/pages/GeneralSettings/VectorDatabase")
 );
 const GeneralSecurity = lazy(() => import("@/pages/GeneralSettings/Security"));
+const GeneralBrowserExtension = lazy(
+  () => import("@/pages/GeneralSettings/BrowserExtensionApiKey")
+);
 const WorkspaceSettings = lazy(() => import("@/pages/WorkspaceSettings"));
 const EmbedConfigSetup = lazy(
   () => import("@/pages/GeneralSettings/EmbedConfigs")
@@ -157,6 +160,10 @@ export default function App() {
                   path="/settings/api-keys"
                   element={<AdminRoute Component={GeneralApiKeys} />}
                 />
+                <Route
+                  path="/settings/browser-extension"
+                  element={<ManagerRoute Component={GeneralBrowserExtension} />}
+                />
                 <Route
                   path="/settings/workspace-chats"
                   element={<ManagerRoute Component={GeneralChats} />}
diff --git a/frontend/src/components/SettingsSidebar/index.jsx b/frontend/src/components/SettingsSidebar/index.jsx
index 9fa6fd611..54cf9b4a1 100644
--- a/frontend/src/components/SettingsSidebar/index.jsx
+++ b/frontend/src/components/SettingsSidebar/index.jsx
@@ -332,6 +332,12 @@ const SidebarOptions = ({ user = null, t }) => (
           flex: true,
           roles: ["admin"],
         },
+        {
+          btnText: t("settings.browser-extension"),
+          href: paths.settings.browserExtension(),
+          flex: true,
+          roles: ["admin", "manager"],
+        },
       ]}
     />
     <Option
diff --git a/frontend/src/locales/de/common.js b/frontend/src/locales/de/common.js
index d82e9fbdb..5f0c64a98 100644
--- a/frontend/src/locales/de/common.js
+++ b/frontend/src/locales/de/common.js
@@ -37,6 +37,7 @@ const TRANSLATIONS = {
     tools: "Werkzeuge",
     "experimental-features": "Experimentelle Funktionen",
     contact: "Support kontaktieren",
+    "browser-extension": "Browser-Erweiterung",
   },
 
   login: {
diff --git a/frontend/src/locales/en/common.js b/frontend/src/locales/en/common.js
index a64d37d84..3954e3f58 100644
--- a/frontend/src/locales/en/common.js
+++ b/frontend/src/locales/en/common.js
@@ -38,6 +38,7 @@ const TRANSLATIONS = {
     tools: "Tools",
     "experimental-features": "Experimental Features",
     contact: "Contact Support",
+    "browser-extension": "Browser Extension",
   },
 
   // Page Definitions
diff --git a/frontend/src/locales/es/common.js b/frontend/src/locales/es/common.js
index f14f1e81b..ba9d6eab6 100644
--- a/frontend/src/locales/es/common.js
+++ b/frontend/src/locales/es/common.js
@@ -37,6 +37,7 @@ const TRANSLATIONS = {
     tools: "Herramientas",
     "experimental-features": "Funciones Experimentales",
     contact: "Contactar Soporte",
+    "browser-extension": "Extensión del navegador",
   },
 
   login: {
diff --git a/frontend/src/locales/fr/common.js b/frontend/src/locales/fr/common.js
index 0a3473013..8c5d79f4f 100644
--- a/frontend/src/locales/fr/common.js
+++ b/frontend/src/locales/fr/common.js
@@ -38,6 +38,7 @@ const TRANSLATIONS = {
     tools: "Outils",
     "experimental-features": "Fonctionnalités Expérimentales",
     contact: "Contacter le Support",
+    "browser-extension": "Extension de navigateur",
   },
 
   // Page Definitions
diff --git a/frontend/src/locales/he/common.js b/frontend/src/locales/he/common.js
index a6b57e58a..a985a33f2 100644
--- a/frontend/src/locales/he/common.js
+++ b/frontend/src/locales/he/common.js
@@ -38,6 +38,7 @@ const TRANSLATIONS = {
     tools: "כלים",
     "experimental-features": "תכונות ניסיוניות",
     contact: "צור קשר עם התמיכה",
+    "browser-extension": "תוסף דפדפן",
   },
 
   // Page Definitions
diff --git a/frontend/src/locales/it/common.js b/frontend/src/locales/it/common.js
index 8b74bd4fd..40da81ee2 100644
--- a/frontend/src/locales/it/common.js
+++ b/frontend/src/locales/it/common.js
@@ -38,6 +38,7 @@ const TRANSLATIONS = {
     tools: "Strumenti",
     "experimental-features": "Caratteristiche sperimentali",
     contact: "Contatta il Supporto",
+    "browser-extension": "Estensione del browser",
   },
 
   // Page Definitions
diff --git a/frontend/src/locales/ko/common.js b/frontend/src/locales/ko/common.js
index eb5b34dfe..247ead951 100644
--- a/frontend/src/locales/ko/common.js
+++ b/frontend/src/locales/ko/common.js
@@ -38,6 +38,7 @@ const TRANSLATIONS = {
     tools: "도구",
     "experimental-features": "실험적 기능",
     contact: "지원팀 연락",
+    "browser-extension": "브라우저 확장 프로그램",
   },
 
   // Page Definitions
diff --git a/frontend/src/locales/pt_BR/common.js b/frontend/src/locales/pt_BR/common.js
index a0f8d495c..078105b92 100644
--- a/frontend/src/locales/pt_BR/common.js
+++ b/frontend/src/locales/pt_BR/common.js
@@ -38,6 +38,7 @@ const TRANSLATIONS = {
     tools: "Ferramentas",
     "experimental-features": "Recursos Experimentais",
     contact: "Contato com Suporte",
+    "browser-extension": "Extensão do navegador",
   },
 
   // Page Definitions
diff --git a/frontend/src/locales/ru/common.js b/frontend/src/locales/ru/common.js
index b898c79b2..dc5284064 100644
--- a/frontend/src/locales/ru/common.js
+++ b/frontend/src/locales/ru/common.js
@@ -37,6 +37,7 @@ const TRANSLATIONS = {
     tools: "Инструменты",
     "experimental-features": "Экспериментальные функции",
     contact: "联系支持Связаться с Поддержкой",
+    "browser-extension": "Расширение браузера",
   },
 
   login: {
diff --git a/frontend/src/locales/zh/common.js b/frontend/src/locales/zh/common.js
index dd369a370..318757060 100644
--- a/frontend/src/locales/zh/common.js
+++ b/frontend/src/locales/zh/common.js
@@ -39,6 +39,7 @@ const TRANSLATIONS = {
     tools: "工具",
     "experimental-features": "实验功能",
     contact: "联系支持",
+    "browser-extension": "浏览器扩展",
   },
 
   // Page Definitions
diff --git a/frontend/src/models/browserExtensionApiKey.js b/frontend/src/models/browserExtensionApiKey.js
new file mode 100644
index 000000000..d487ed06d
--- /dev/null
+++ b/frontend/src/models/browserExtensionApiKey.js
@@ -0,0 +1,42 @@
+import { API_BASE } from "@/utils/constants";
+import { baseHeaders } from "@/utils/request";
+
+const BrowserExtensionApiKey = {
+  getAll: async () => {
+    return await fetch(`${API_BASE}/browser-extension/api-keys`, {
+      method: "GET",
+      headers: baseHeaders(),
+    })
+      .then((res) => res.json())
+      .catch((e) => {
+        console.error(e);
+        return { success: false, error: e.message, apiKeys: [] };
+      });
+  },
+
+  generateKey: async () => {
+    return await fetch(`${API_BASE}/browser-extension/api-keys/new`, {
+      method: "POST",
+      headers: baseHeaders(),
+    })
+      .then((res) => res.json())
+      .catch((e) => {
+        console.error(e);
+        return { success: false, error: e.message };
+      });
+  },
+
+  revoke: async (id) => {
+    return await fetch(`${API_BASE}/browser-extension/api-keys/${id}`, {
+      method: "DELETE",
+      headers: baseHeaders(),
+    })
+      .then((res) => res.json())
+      .catch((e) => {
+        console.error(e);
+        return { success: false, error: e.message };
+      });
+  },
+};
+
+export default BrowserExtensionApiKey;
diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx
new file mode 100644
index 000000000..e43996a41
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/BrowserExtensionApiKeyRow/index.jsx
@@ -0,0 +1,120 @@
+import { useRef, useState } from "react";
+import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
+import showToast from "@/utils/toast";
+import { Trash, Copy, Check, Plug } from "@phosphor-icons/react";
+import { POPUP_BROWSER_EXTENSION_EVENT } from "@/utils/constants";
+import { Tooltip } from "react-tooltip";
+
+export default function BrowserExtensionApiKeyRow({
+  apiKey,
+  removeApiKey,
+  connectionString,
+  isMultiUser,
+}) {
+  const rowRef = useRef(null);
+  const [copied, setCopied] = useState(false);
+
+  const handleRevoke = async () => {
+    if (
+      !window.confirm(
+        `Are you sure you want to revoke this browser extension API key?\nAfter you do this it will no longer be useable.\n\nThis action is irreversible.`
+      )
+    )
+      return false;
+
+    const result = await BrowserExtensionApiKey.revoke(apiKey.id);
+    if (result.success) {
+      removeApiKey(apiKey.id);
+      showToast("Browser Extension API Key permanently revoked", "info", {
+        clear: true,
+      });
+    } else {
+      showToast("Failed to revoke API Key", "error", {
+        clear: true,
+      });
+    }
+  };
+
+  const handleCopy = () => {
+    navigator.clipboard.writeText(connectionString);
+    showToast("Connection string copied to clipboard", "success", {
+      clear: true,
+    });
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  };
+
+  const handleConnect = () => {
+    // Sending a message to Chrome extension to pop up the extension window
+    // This will open the extension window and attempt to connect with the API key
+    window.postMessage(
+      { type: POPUP_BROWSER_EXTENSION_EVENT, apiKey: connectionString },
+      "*"
+    );
+    showToast("Attempting to connect to browser extension...", "info", {
+      clear: true,
+    });
+  };
+
+  return (
+    <tr
+      ref={rowRef}
+      className="bg-transparent text-white text-opacity-80 text-sm font-medium"
+    >
+      <td scope="row" className="px-6 py-4 whitespace-nowrap flex items-center">
+        <span className="mr-2 font-mono">{connectionString}</span>
+        <div className="flex items-center space-x-2">
+          <button
+            onClick={handleCopy}
+            data-tooltip-id={`copy-connection-text-${apiKey.id}`}
+            data-tooltip-content="Copy connection string"
+            className="text-white hover:text-white/80 transition-colors duration-200 p-1 rounded"
+          >
+            {copied ? (
+              <Check className="h-5 w-5 text-green-500" />
+            ) : (
+              <Copy className="h-5 w-5" />
+            )}
+            <Tooltip
+              id={`copy-connection-text-${apiKey.id}`}
+              place="bottom"
+              delayShow={300}
+              className="allm-tooltip !allm-text-xs"
+            />
+          </button>
+
+          <button
+            onClick={handleConnect}
+            data-tooltip-id={`auto-connection-${apiKey.id}`}
+            data-tooltip-content="Automatically connect to extension"
+            className="text-white hover:text-white/80 transition-colors duration-200 p-1 rounded"
+          >
+            <Plug className="h-5 w-5" />
+            <Tooltip
+              id={`auto-connection-${apiKey.id}`}
+              place="bottom"
+              delayShow={300}
+              className="allm-tooltip !allm-text-xs"
+            />
+          </button>
+        </div>
+      </td>
+      {isMultiUser && (
+        <td className="px-6 py-4">
+          {apiKey.user ? apiKey.user.username : "N/A"}
+        </td>
+      )}
+      <td className="px-6 py-4">
+        {new Date(apiKey.createdAt).toLocaleString()}
+      </td>
+      <td className="px-6 py-4">
+        <button
+          onClick={handleRevoke}
+          className="font-medium px-2 py-1 rounded-lg hover:bg-sidebar-gradient text-white hover:text-white/80 hover:bg-opacity-20"
+        >
+          <Trash className="h-5 w-5" />
+        </button>
+      </td>
+    </tr>
+  );
+}
diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx
new file mode 100644
index 000000000..ee3f86786
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/NewBrowserExtensionApiKeyModal/index.jsx
@@ -0,0 +1,127 @@
+import React, { useEffect, useState } from "react";
+import { X } from "@phosphor-icons/react";
+import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
+import { fullApiUrl, POPUP_BROWSER_EXTENSION_EVENT } from "@/utils/constants";
+
+export default function NewBrowserExtensionApiKeyModal({
+  closeModal,
+  onSuccess,
+  isMultiUser,
+}) {
+  const [apiKey, setApiKey] = useState(null);
+  const [error, setError] = useState(null);
+  const [copied, setCopied] = useState(false);
+
+  const handleCreate = async (e) => {
+    setError(null);
+    e.preventDefault();
+
+    const { apiKey: newApiKey, error } =
+      await BrowserExtensionApiKey.generateKey();
+    if (!!newApiKey) {
+      const fullApiKey = `${fullApiUrl()}|${newApiKey}`;
+      setApiKey(fullApiKey);
+      onSuccess();
+
+      window.postMessage(
+        { type: POPUP_BROWSER_EXTENSION_EVENT, apiKey: fullApiKey },
+        "*"
+      );
+    }
+    setError(error);
+  };
+
+  const copyApiKey = () => {
+    if (!apiKey) return false;
+    window.navigator.clipboard.writeText(apiKey);
+    setCopied(true);
+  };
+
+  useEffect(() => {
+    function resetStatus() {
+      if (!copied) return false;
+      setTimeout(() => {
+        setCopied(false);
+      }, 3000);
+    }
+    resetStatus();
+  }, [copied]);
+
+  return (
+    <div className="relative w-[500px] max-w-2xl max-h-full">
+      <div className="relative bg-main-gradient rounded-lg shadow">
+        <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
+          <h3 className="text-xl font-semibold text-white">
+            New Browser Extension API Key
+          </h3>
+          <button
+            onClick={closeModal}
+            type="button"
+            className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border border-none cursor-pointer"
+          >
+            <X className="text-gray-300 text-lg" />
+          </button>
+        </div>
+        <form onSubmit={handleCreate}>
+          <div className="p-6 space-y-6 flex h-full w-full">
+            <div className="w-full flex flex-col gap-y-4">
+              {error && <p className="text-red-400 text-sm">Error: {error}</p>}
+              {apiKey && (
+                <input
+                  type="text"
+                  defaultValue={apiKey}
+                  disabled={true}
+                  className="rounded-lg px-4 py-2 text-white bg-zinc-900 border border-gray-500/50 border-none"
+                />
+              )}
+              {isMultiUser && (
+                <p className="text-yellow-300 text-xs md:text-sm font-semibold">
+                  Warning: You are in multi-user mode, this API key will allow
+                  access to all workspaces associated with your account. Please
+                  share it cautiously.
+                </p>
+              )}
+              <p className="text-white text-xs md:text-sm">
+                After clicking "Create API Key", AnythingLLM will attempt to
+                connect to your browser extension automatically.
+              </p>
+              <p className="text-white text-xs md:text-sm">
+                If you see "Connected to AnythingLLM" in the extension, the
+                connection was successful. If not, please copy the connection
+                string and paste it into the extension manually.
+              </p>
+            </div>
+          </div>
+          <div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50">
+            {!apiKey ? (
+              <>
+                <button
+                  onClick={closeModal}
+                  type="button"
+                  className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300 border-none"
+                >
+                  Cancel
+                </button>
+                <button
+                  type="submit"
+                  className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800 border-none"
+                >
+                  Create API Key
+                </button>
+              </>
+            ) : (
+              <button
+                onClick={copyApiKey}
+                type="button"
+                disabled={copied}
+                className="w-full transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800 text-center justify-center border-none cursor-pointer"
+              >
+                {copied ? "API Key Copied!" : "Copy API Key"}
+              </button>
+            )}
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}
diff --git a/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx
new file mode 100644
index 000000000..1bcb7c13f
--- /dev/null
+++ b/frontend/src/pages/GeneralSettings/BrowserExtensionApiKey/index.jsx
@@ -0,0 +1,133 @@
+import { useEffect, useState } from "react";
+import Sidebar from "@/components/SettingsSidebar";
+import { isMobile } from "react-device-detect";
+import * as Skeleton from "react-loading-skeleton";
+import "react-loading-skeleton/dist/skeleton.css";
+import { PlusCircle } from "@phosphor-icons/react";
+import BrowserExtensionApiKey from "@/models/browserExtensionApiKey";
+import BrowserExtensionApiKeyRow from "./BrowserExtensionApiKeyRow";
+import CTAButton from "@/components/lib/CTAButton";
+import NewBrowserExtensionApiKeyModal from "./NewBrowserExtensionApiKeyModal";
+import ModalWrapper from "@/components/ModalWrapper";
+import { useModal } from "@/hooks/useModal";
+import { fullApiUrl } from "@/utils/constants";
+
+export default function BrowserExtensionApiKeys() {
+  const [loading, setLoading] = useState(true);
+  const [apiKeys, setApiKeys] = useState([]);
+  const [error, setError] = useState(null);
+  const { isOpen, openModal, closeModal } = useModal();
+  const [isMultiUser, setIsMultiUser] = useState(false);
+
+  useEffect(() => {
+    fetchExistingKeys();
+  }, []);
+
+  const fetchExistingKeys = async () => {
+    const result = await BrowserExtensionApiKey.getAll();
+    if (result.success) {
+      setApiKeys(result.apiKeys);
+      setIsMultiUser(result.apiKeys.some((key) => key.user !== null));
+    } else {
+      setError(result.error || "Failed to fetch API keys");
+    }
+    setLoading(false);
+  };
+
+  const removeApiKey = (id) => {
+    setApiKeys((prevKeys) => prevKeys.filter((apiKey) => apiKey.id !== id));
+  };
+
+  return (
+    <div className="w-screen h-screen overflow-hidden bg-sidebar flex">
+      <Sidebar />
+      <div
+        style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
+        className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-main-gradient w-full h-full overflow-y-scroll"
+      >
+        <div className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px] md:py-6 py-16">
+          <div className="w-full flex flex-col gap-y-1 pb-6 border-white border-b-2 border-opacity-10">
+            <div className="items-center flex gap-x-4">
+              <p className="text-lg leading-6 font-bold text-white">
+                Browser Extension API Keys
+              </p>
+            </div>
+            <p className="text-xs leading-[18px] font-base text-white text-opacity-60">
+              Manage API keys for browser extensions connecting to your
+              AnythingLLM instance.
+            </p>
+          </div>
+          <div className="w-full justify-end flex">
+            <CTAButton onClick={openModal} className="mt-3 mr-0 -mb-6 z-10">
+              <PlusCircle className="h-4 w-4" weight="bold" />
+              Generate New API Key
+            </CTAButton>
+          </div>
+          {loading ? (
+            <Skeleton.default
+              height="80vh"
+              width="100%"
+              highlightColor="#3D4147"
+              baseColor="#2C2F35"
+              count={1}
+              className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6"
+              containerClassName="flex w-full"
+            />
+          ) : error ? (
+            <div className="text-red-500 mt-6">Error: {error}</div>
+          ) : (
+            <table className="w-full text-sm text-left rounded-lg mt-6">
+              <thead className="text-white text-opacity-80 text-xs leading-[18px] font-bold uppercase border-white border-b border-opacity-60">
+                <tr>
+                  <th scope="col" className="px-6 py-3 rounded-tl-lg">
+                    Extension Connection String
+                  </th>
+                  {isMultiUser && (
+                    <th scope="col" className="px-6 py-3">
+                      Created By
+                    </th>
+                  )}
+                  <th scope="col" className="px-6 py-3">
+                    Created At
+                  </th>
+                  <th scope="col" className="px-6 py-3 rounded-tr-lg">
+                    Actions
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                {apiKeys.length === 0 ? (
+                  <tr className="bg-transparent text-white text-opacity-80 text-sm font-medium">
+                    <td
+                      colSpan={isMultiUser ? "4" : "3"}
+                      className="px-6 py-4 text-center"
+                    >
+                      No API keys found
+                    </td>
+                  </tr>
+                ) : (
+                  apiKeys.map((apiKey) => (
+                    <BrowserExtensionApiKeyRow
+                      key={apiKey.id}
+                      apiKey={apiKey}
+                      removeApiKey={removeApiKey}
+                      connectionString={`${fullApiUrl()}|${apiKey.key}`}
+                      isMultiUser={isMultiUser}
+                    />
+                  ))
+                )}
+              </tbody>
+            </table>
+          )}
+        </div>
+      </div>
+      <ModalWrapper isOpen={isOpen}>
+        <NewBrowserExtensionApiKeyModal
+          closeModal={closeModal}
+          onSuccess={fetchExistingKeys}
+          isMultiUser={isMultiUser}
+        />
+      </ModalWrapper>
+    </div>
+  );
+}
diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js
index 6a9773b9e..1fad7389f 100644
--- a/frontend/src/utils/constants.js
+++ b/frontend/src/utils/constants.js
@@ -41,3 +41,5 @@ export function fullApiUrl() {
   if (API_BASE !== "/api") return API_BASE;
   return `${window.location.origin}/api`;
 }
+
+export const POPUP_BROWSER_EXTENSION_EVENT = "NEW_BROWSER_EXTENSION_CONNECTION";
diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js
index 00fce5117..28d36025c 100644
--- a/frontend/src/utils/paths.js
+++ b/frontend/src/utils/paths.js
@@ -138,6 +138,9 @@ export default {
     embedChats: () => {
       return `/settings/embed-chats`;
     },
+    browserExtension: () => {
+      return `/settings/browser-extension`;
+    },
     experimental: () => {
       return `/settings/beta-features`;
     },
diff --git a/server/endpoints/browserExtension.js b/server/endpoints/browserExtension.js
new file mode 100644
index 000000000..844da00e7
--- /dev/null
+++ b/server/endpoints/browserExtension.js
@@ -0,0 +1,224 @@
+const { Workspace } = require("../models/workspace");
+const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
+const { Document } = require("../models/documents");
+const {
+  validBrowserExtensionApiKey,
+} = require("../utils/middleware/validBrowserExtensionApiKey");
+const { CollectorApi } = require("../utils/collectorApi");
+const { reqBody, multiUserMode, userFromSession } = require("../utils/http");
+const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const {
+  flexUserRoleValid,
+  ROLES,
+} = require("../utils/middleware/multiUserProtected");
+const { Telemetry } = require("../models/telemetry");
+
+function browserExtensionEndpoints(app) {
+  if (!app) return;
+
+  app.get(
+    "/browser-extension/check",
+    [validBrowserExtensionApiKey],
+    async (request, response) => {
+      try {
+        const user = await userFromSession(request, response);
+        const workspaces = multiUserMode(response)
+          ? await Workspace.whereWithUser(user)
+          : await Workspace.where();
+
+        const apiKeyId = response.locals.apiKey.id;
+        response.status(200).json({
+          connected: true,
+          workspaces,
+          apiKeyId,
+        });
+      } catch (error) {
+        console.error(error);
+        response
+          .status(500)
+          .json({ connected: false, error: "Failed to fetch workspaces" });
+      }
+    }
+  );
+
+  app.delete(
+    "/browser-extension/disconnect",
+    [validBrowserExtensionApiKey],
+    async (_request, response) => {
+      try {
+        const apiKeyId = response.locals.apiKey.id;
+        const { success, error } =
+          await BrowserExtensionApiKey.delete(apiKeyId);
+        if (!success) throw new Error(error);
+        response.status(200).json({ success: true });
+      } catch (error) {
+        console.error(error);
+        response
+          .status(500)
+          .json({ error: "Failed to disconnect and revoke API key" });
+      }
+    }
+  );
+
+  app.get(
+    "/browser-extension/workspaces",
+    [validBrowserExtensionApiKey],
+    async (request, response) => {
+      try {
+        const user = await userFromSession(request, response);
+        const workspaces = multiUserMode(response)
+          ? await Workspace.whereWithUser(user)
+          : await Workspace.where();
+
+        response.status(200).json({ workspaces });
+      } catch (error) {
+        console.error(error);
+        response.status(500).json({ error: "Failed to fetch workspaces" });
+      }
+    }
+  );
+
+  app.post(
+    "/browser-extension/embed-content",
+    [validBrowserExtensionApiKey],
+    async (request, response) => {
+      try {
+        const { workspaceId, textContent, metadata } = reqBody(request);
+        const user = await userFromSession(request, response);
+        const workspace = multiUserMode(response)
+          ? await Workspace.getWithUser(user, { id: parseInt(workspaceId) })
+          : await Workspace.get({ id: parseInt(workspaceId) });
+
+        if (!workspace) {
+          response.status(404).json({ error: "Workspace not found" });
+          return;
+        }
+
+        const Collector = new CollectorApi();
+        const { success, reason, documents } = await Collector.processRawText(
+          textContent,
+          metadata
+        );
+
+        if (!success) {
+          response.status(500).json({ success: false, error: reason });
+          return;
+        }
+
+        const { failedToEmbed = [], errors = [] } = await Document.addDocuments(
+          workspace,
+          [documents[0].location],
+          user?.id
+        );
+
+        if (failedToEmbed.length > 0) {
+          response.status(500).json({ success: false, error: errors[0] });
+          return;
+        }
+
+        await Telemetry.sendTelemetry("browser_extension_embed_content");
+        response.status(200).json({ success: true });
+      } catch (error) {
+        console.error(error);
+        response.status(500).json({ error: "Failed to embed content" });
+      }
+    }
+  );
+
+  app.post(
+    "/browser-extension/upload-content",
+    [validBrowserExtensionApiKey],
+    async (request, response) => {
+      try {
+        const { textContent, metadata } = reqBody(request);
+        const Collector = new CollectorApi();
+        const { success, reason } = await Collector.processRawText(
+          textContent,
+          metadata
+        );
+
+        if (!success) {
+          response.status(500).json({ success: false, error: reason });
+          return;
+        }
+
+        await Telemetry.sendTelemetry("browser_extension_upload_content");
+        response.status(200).json({ success: true });
+      } catch (error) {
+        console.error(error);
+        response.status(500).json({ error: "Failed to embed content" });
+      }
+    }
+  );
+
+  // Internal endpoints for managing API keys
+  app.get(
+    "/browser-extension/api-keys",
+    [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+    async (request, response) => {
+      try {
+        const user = await userFromSession(request, response);
+        const apiKeys = multiUserMode(response)
+          ? await BrowserExtensionApiKey.whereWithUser(user)
+          : await BrowserExtensionApiKey.where();
+
+        response.status(200).json({ success: true, apiKeys });
+      } catch (error) {
+        console.error(error);
+        response
+          .status(500)
+          .json({ success: false, error: "Failed to fetch API keys" });
+      }
+    }
+  );
+
+  app.post(
+    "/browser-extension/api-keys/new",
+    [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+    async (request, response) => {
+      try {
+        const user = await userFromSession(request, response);
+        const { apiKey, error } = await BrowserExtensionApiKey.create(
+          user?.id || null
+        );
+        if (error) throw new Error(error);
+        response.status(200).json({
+          apiKey: apiKey.key,
+        });
+      } catch (error) {
+        console.error(error);
+        response.status(500).json({ error: "Failed to create API key" });
+      }
+    }
+  );
+
+  app.delete(
+    "/browser-extension/api-keys/:id",
+    [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+    async (request, response) => {
+      try {
+        const { id } = request.params;
+        const user = await userFromSession(request, response);
+
+        if (multiUserMode(response) && user.role !== ROLES.admin) {
+          const apiKey = await BrowserExtensionApiKey.get({
+            id: parseInt(id),
+            user_id: user?.id,
+          });
+          if (!apiKey) {
+            return response.status(403).json({ error: "Unauthorized" });
+          }
+        }
+
+        const { success, error } = await BrowserExtensionApiKey.delete(id);
+        if (!success) throw new Error(error);
+        response.status(200).json({ success: true });
+      } catch (error) {
+        console.error(error);
+        response.status(500).json({ error: "Failed to revoke API key" });
+      }
+    }
+  );
+}
+
+module.exports = { browserExtensionEndpoints };
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
index d49765d40..9e57b3c35 100644
--- a/server/endpoints/system.js
+++ b/server/endpoints/system.js
@@ -52,6 +52,7 @@ const {
 } = require("../utils/PasswordRecovery");
 const { SlashCommandPresets } = require("../models/slashCommandsPresets");
 const { EncryptionManager } = require("../utils/EncryptionManager");
+const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
 
 function systemEndpoints(app) {
   if (!app) return;
@@ -495,6 +496,7 @@ function systemEndpoints(app) {
           limit_user_messages: false,
           message_limit: 25,
         });
+        await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id);
 
         await updateENV(
           {
diff --git a/server/index.js b/server/index.js
index 757f35344..041d34c95 100644
--- a/server/index.js
+++ b/server/index.js
@@ -24,6 +24,7 @@ const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads");
 const { documentEndpoints } = require("./endpoints/document");
 const { agentWebsocket } = require("./endpoints/agentWebsocket");
 const { experimentalEndpoints } = require("./endpoints/experimental");
+const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
 const app = express();
 const apiRouter = express.Router();
 const FILE_LIMIT = "3GB";
@@ -62,6 +63,9 @@ developerEndpoints(app, apiRouter);
 // Externally facing embedder endpoints
 embeddedEndpoints(apiRouter);
 
+// Externally facing browser extension endpoints
+browserExtensionEndpoints(apiRouter);
+
 if (process.env.NODE_ENV !== "development") {
   const { MetaGenerator } = require("./utils/boot/MetaGenerator");
   const IndexPage = new MetaGenerator();
diff --git a/server/models/browserExtensionApiKey.js b/server/models/browserExtensionApiKey.js
new file mode 100644
index 000000000..21c1a3a01
--- /dev/null
+++ b/server/models/browserExtensionApiKey.js
@@ -0,0 +1,168 @@
+const prisma = require("../utils/prisma");
+const { SystemSettings } = require("./systemSettings");
+const { ROLES } = require("../utils/middleware/multiUserProtected");
+
+const BrowserExtensionApiKey = {
+  /**
+   * Creates a new secret for a browser extension API key.
+   * @returns {string} brx-*** API key to use with extension
+   */
+  makeSecret: () => {
+    const uuidAPIKey = require("uuid-apikey");
+    return `brx-${uuidAPIKey.create().apiKey}`;
+  },
+
+  /**
+   * Creates a new api key for the browser Extension
+   * @param {number|null} userId - User id to associate creation of key with.
+   * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|null, error:string|null}>}
+   */
+  create: async function (userId = null) {
+    try {
+      const apiKey = await prisma.browser_extension_api_keys.create({
+        data: {
+          key: this.makeSecret(),
+          user_id: userId,
+        },
+      });
+      return { apiKey, error: null };
+    } catch (error) {
+      console.error("Failed to create browser extension API key", error);
+      return { apiKey: null, error: error.message };
+    }
+  },
+
+  /**
+   * Validated existing API key
+   * @param {string} key
+   * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|boolean}>}
+   */
+  validate: async function (key) {
+    if (!key.startsWith("brx-")) return false;
+    const apiKey = await prisma.browser_extension_api_keys.findUnique({
+      where: { key: key.toString() },
+      include: { user: true },
+    });
+    if (!apiKey) return false;
+
+    const multiUserMode = await SystemSettings.isMultiUserMode();
+    if (!multiUserMode) return apiKey; // In single-user mode, all keys are valid
+
+    // In multi-user mode, check if the key is associated with a user
+    return apiKey.user_id ? apiKey : false;
+  },
+
+  /**
+   * Fetches browser api key by params.
+   * @param {object} clause - Prisma props for search
+   * @returns {Promise<{apiKey: import("@prisma/client").browser_extension_api_keys|boolean}>}
+   */
+  get: async function (clause = {}) {
+    try {
+      const apiKey = await prisma.browser_extension_api_keys.findFirst({
+        where: clause,
+      });
+      return apiKey;
+    } catch (error) {
+      console.error("FAILED TO GET BROWSER EXTENSION API KEY.", error.message);
+      return null;
+    }
+  },
+
+  /**
+   * Deletes browser api key by db id.
+   * @param {number} id - database id of browser key
+   * @returns {Promise<{success: boolean, error:string|null}>}
+   */
+  delete: async function (id) {
+    try {
+      await prisma.browser_extension_api_keys.delete({
+        where: { id: parseInt(id) },
+      });
+      return { success: true, error: null };
+    } catch (error) {
+      console.error("Failed to delete browser extension API key", error);
+      return { success: false, error: error.message };
+    }
+  },
+
+  /**
+   * Gets browser keys by params
+   * @param {object} clause 
+   * @param {number|null} limit 
+   * @param {object|null} orderBy 
+   * @returns {Promise<import("@prisma/client").browser_extension_api_keys[]>}
+   */
+  where: async function (clause = {}, limit = null, orderBy = null) {
+    try {
+      const apiKeys = await prisma.browser_extension_api_keys.findMany({
+        where: clause,
+        ...(limit !== null ? { take: limit } : {}),
+        ...(orderBy !== null ? { orderBy } : {}),
+        include: { user: true },
+      });
+      return apiKeys;
+    } catch (error) {
+      console.error("FAILED TO GET BROWSER EXTENSION API KEYS.", error.message);
+      return [];
+    }
+  },
+
+  /**
+   * Get browser API keys for user
+   * @param {import("@prisma/client").users} user
+   * @param {object} clause 
+   * @param {number|null} limit 
+   * @param {object|null} orderBy 
+   * @returns {Promise<import("@prisma/client").browser_extension_api_keys[]>}
+   */
+  whereWithUser: async function (
+    user,
+    clause = {},
+    limit = null,
+    orderBy = null
+  ) {
+    // Admin can view and use any keys
+    if ([ROLES.admin].includes(user.role))
+      return await this.where(clause, limit, orderBy);
+
+    try {
+      const apiKeys = await prisma.browser_extension_api_keys.findMany({
+        where: {
+          ...clause,
+          user_id: user.id,
+        },
+        include: { user: true },
+        ...(limit !== null ? { take: limit } : {}),
+        ...(orderBy !== null ? { orderBy } : {}),
+      });
+      return apiKeys;
+    } catch (error) {
+      console.error(error.message);
+      return [];
+    }
+  },
+
+  /**
+   * Updates owner of all DB ids to new admin.
+   * @param {number} userId
+   * @returns {Promise<void>}
+   */
+  migrateApiKeysToMultiUser: async function (userId) {
+    try {
+      await prisma.browser_extension_api_keys.updateMany({
+        where: {
+          user_id: null,
+        },
+        data: {
+          user_id: userId,
+        },
+      });
+      console.log("Successfully migrated API keys to multi-user mode");
+    } catch (error) {
+      console.error("Error migrating API keys to multi-user mode:", error);
+    }
+  },
+};
+
+module.exports = { BrowserExtensionApiKey };
diff --git a/server/prisma/migrations/20240824005054_init/migration.sql b/server/prisma/migrations/20240824005054_init/migration.sql
new file mode 100644
index 000000000..7dc4632b6
--- /dev/null
+++ b/server/prisma/migrations/20240824005054_init/migration.sql
@@ -0,0 +1,15 @@
+-- CreateTable
+CREATE TABLE "browser_extension_api_keys" (
+    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+    "key" TEXT NOT NULL,
+    "user_id" INTEGER,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "lastUpdatedAt" DATETIME NOT NULL,
+    CONSTRAINT "browser_extension_api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "browser_extension_api_keys_key_key" ON "browser_extension_api_keys"("key");
+
+-- CreateIndex
+CREATE INDEX "browser_extension_api_keys_user_id_idx" ON "browser_extension_api_keys"("user_id");
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index b45e29119..276b9eaf9 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -76,6 +76,7 @@ model users {
   password_reset_tokens       password_reset_tokens[]
   workspace_agent_invocations workspace_agent_invocations[]
   slash_command_presets       slash_command_presets[]
+  browser_extension_api_keys  browser_extension_api_keys[]
 }
 
 model recovery_codes {
@@ -298,3 +299,14 @@ model document_sync_executions {
   createdAt DateTime             @default(now())
   queue     document_sync_queues @relation(fields: [queueId], references: [id], onDelete: Cascade)
 }
+
+model browser_extension_api_keys {
+  id            Int      @id @default(autoincrement())
+  key           String   @unique
+  user_id       Int?
+  createdAt     DateTime @default(now())
+  lastUpdatedAt DateTime @updatedAt
+  user          users?   @relation(fields: [user_id], references: [id], onDelete: Cascade)
+
+  @@index([user_id])
+}
\ No newline at end of file
diff --git a/server/utils/middleware/validBrowserExtensionApiKey.js b/server/utils/middleware/validBrowserExtensionApiKey.js
new file mode 100644
index 000000000..75485a778
--- /dev/null
+++ b/server/utils/middleware/validBrowserExtensionApiKey.js
@@ -0,0 +1,36 @@
+const {
+  BrowserExtensionApiKey,
+} = require("../../models/browserExtensionApiKey");
+const { SystemSettings } = require("../../models/systemSettings");
+const { User } = require("../../models/user");
+
+async function validBrowserExtensionApiKey(request, response, next) {
+  const multiUserMode = await SystemSettings.isMultiUserMode();
+  response.locals.multiUserMode = multiUserMode;
+
+  const auth = request.header("Authorization");
+  const bearerKey = auth ? auth.split(" ")[1] : null;
+  if (!bearerKey) {
+    response.status(403).json({
+      error: "No valid API key found.",
+    });
+    return;
+  }
+
+  const apiKey = await BrowserExtensionApiKey.validate(bearerKey);
+  if (!apiKey) {
+    response.status(403).json({
+      error: "No valid API key found.",
+    });
+    return;
+  }
+
+  if (multiUserMode) {
+    response.locals.user = await User.get({ id: apiKey.user_id });
+  }
+
+  response.locals.apiKey = apiKey;
+  next();
+}
+
+module.exports = { validBrowserExtensionApiKey };
-- 
GitLab