mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2025-03-13 21:52:22 +00:00
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>
This commit is contained in:
parent
fc6d7359b6
commit
29df483a27
30 changed files with 918 additions and 3 deletions
.github/workflows
.gitmodulesREADME.mdbrowser-extensionembedfrontend/src
App.jsx
components/SettingsSidebar
locales
de
en
es
fr
he
it
ko
pt_BR
ru
zh
models
pages/GeneralSettings/BrowserExtensionApiKey
utils
server
2
.github/workflows/dev-build.yaml
vendored
2
.github/workflows/dev-build.yaml
vendored
|
@ -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/*'
|
||||
|
|
3
.gitmodules
vendored
3
.gitmodules
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
browser-extension
Submodule
1
browser-extension
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit d9b28cc1e23b64fdb4e666d5b5b49cc8e583aabd
|
1
embed
1
embed
|
@ -1 +0,0 @@
|
|||
Subproject commit 22a0848d58e3a758d85d93d9204a72a65854ea94
|
|
@ -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} />}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -37,6 +37,7 @@ const TRANSLATIONS = {
|
|||
tools: "Werkzeuge",
|
||||
"experimental-features": "Experimentelle Funktionen",
|
||||
contact: "Support kontaktieren",
|
||||
"browser-extension": "Browser-Erweiterung",
|
||||
},
|
||||
|
||||
login: {
|
||||
|
|
|
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||
tools: "Tools",
|
||||
"experimental-features": "Experimental Features",
|
||||
contact: "Contact Support",
|
||||
"browser-extension": "Browser Extension",
|
||||
},
|
||||
|
||||
// Page Definitions
|
||||
|
|
|
@ -37,6 +37,7 @@ const TRANSLATIONS = {
|
|||
tools: "Herramientas",
|
||||
"experimental-features": "Funciones Experimentales",
|
||||
contact: "Contactar Soporte",
|
||||
"browser-extension": "Extensión del navegador",
|
||||
},
|
||||
|
||||
login: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||
tools: "כלים",
|
||||
"experimental-features": "תכונות ניסיוניות",
|
||||
contact: "צור קשר עם התמיכה",
|
||||
"browser-extension": "תוסף דפדפן",
|
||||
},
|
||||
|
||||
// Page Definitions
|
||||
|
|
|
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||
tools: "Strumenti",
|
||||
"experimental-features": "Caratteristiche sperimentali",
|
||||
contact: "Contatta il Supporto",
|
||||
"browser-extension": "Estensione del browser",
|
||||
},
|
||||
|
||||
// Page Definitions
|
||||
|
|
|
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||
tools: "도구",
|
||||
"experimental-features": "실험적 기능",
|
||||
contact: "지원팀 연락",
|
||||
"browser-extension": "브라우저 확장 프로그램",
|
||||
},
|
||||
|
||||
// Page Definitions
|
||||
|
|
|
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||
tools: "Ferramentas",
|
||||
"experimental-features": "Recursos Experimentais",
|
||||
contact: "Contato com Suporte",
|
||||
"browser-extension": "Extensão do navegador",
|
||||
},
|
||||
|
||||
// Page Definitions
|
||||
|
|
|
@ -37,6 +37,7 @@ const TRANSLATIONS = {
|
|||
tools: "Инструменты",
|
||||
"experimental-features": "Экспериментальные функции",
|
||||
contact: "联系支持Связаться с Поддержкой",
|
||||
"browser-extension": "Расширение браузера",
|
||||
},
|
||||
|
||||
login: {
|
||||
|
|
|
@ -39,6 +39,7 @@ const TRANSLATIONS = {
|
|||
tools: "工具",
|
||||
"experimental-features": "实验功能",
|
||||
contact: "联系支持",
|
||||
"browser-extension": "浏览器扩展",
|
||||
},
|
||||
|
||||
// Page Definitions
|
||||
|
|
42
frontend/src/models/browserExtensionApiKey.js
Normal file
42
frontend/src/models/browserExtensionApiKey.js
Normal file
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -138,6 +138,9 @@ export default {
|
|||
embedChats: () => {
|
||||
return `/settings/embed-chats`;
|
||||
},
|
||||
browserExtension: () => {
|
||||
return `/settings/browser-extension`;
|
||||
},
|
||||
experimental: () => {
|
||||
return `/settings/beta-features`;
|
||||
},
|
||||
|
|
224
server/endpoints/browserExtension.js
Normal file
224
server/endpoints/browserExtension.js
Normal file
|
@ -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 };
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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();
|
||||
|
|
168
server/models/browserExtensionApiKey.js
Normal file
168
server/models/browserExtensionApiKey.js
Normal file
|
@ -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 };
|
15
server/prisma/migrations/20240824005054_init/migration.sql
Normal file
15
server/prisma/migrations/20240824005054_init/migration.sql
Normal file
|
@ -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");
|
|
@ -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])
|
||||
}
|
36
server/utils/middleware/validBrowserExtensionApiKey.js
Normal file
36
server/utils/middleware/validBrowserExtensionApiKey.js
Normal file
|
@ -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 };
|
Loading…
Add table
Reference in a new issue