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
2
.github/workflows/dev-build.yaml
vendored
2
.github/workflows/dev-build.yaml
vendored
@ -6,7 +6,7 @@ concurrency:
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
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:
|
paths-ignore:
|
||||||
- '**.md'
|
- '**.md'
|
||||||
- 'cloud-deployments/*'
|
- 'cloud-deployments/*'
|
||||||
|
|||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -2,3 +2,6 @@
|
|||||||
branch = main
|
branch = main
|
||||||
path = embed
|
path = embed
|
||||||
url = git@github.com:Mintplex-Labs/anythingllm-embed.git
|
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.
|
- `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.
|
- `collector`: NodeJS express server that process and parses documents from the UI.
|
||||||
- `docker`: Docker instructions and build process + information for building from source.
|
- `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
|
## 🛳 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")
|
() => import("@/pages/GeneralSettings/VectorDatabase")
|
||||||
);
|
);
|
||||||
const GeneralSecurity = lazy(() => import("@/pages/GeneralSettings/Security"));
|
const GeneralSecurity = lazy(() => import("@/pages/GeneralSettings/Security"));
|
||||||
|
const GeneralBrowserExtension = lazy(
|
||||||
|
() => import("@/pages/GeneralSettings/BrowserExtensionApiKey")
|
||||||
|
);
|
||||||
const WorkspaceSettings = lazy(() => import("@/pages/WorkspaceSettings"));
|
const WorkspaceSettings = lazy(() => import("@/pages/WorkspaceSettings"));
|
||||||
const EmbedConfigSetup = lazy(
|
const EmbedConfigSetup = lazy(
|
||||||
() => import("@/pages/GeneralSettings/EmbedConfigs")
|
() => import("@/pages/GeneralSettings/EmbedConfigs")
|
||||||
@ -157,6 +160,10 @@ export default function App() {
|
|||||||
path="/settings/api-keys"
|
path="/settings/api-keys"
|
||||||
element={<AdminRoute Component={GeneralApiKeys} />}
|
element={<AdminRoute Component={GeneralApiKeys} />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/browser-extension"
|
||||||
|
element={<ManagerRoute Component={GeneralBrowserExtension} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings/workspace-chats"
|
path="/settings/workspace-chats"
|
||||||
element={<ManagerRoute Component={GeneralChats} />}
|
element={<ManagerRoute Component={GeneralChats} />}
|
||||||
|
|||||||
@ -332,6 +332,12 @@ const SidebarOptions = ({ user = null, t }) => (
|
|||||||
flex: true,
|
flex: true,
|
||||||
roles: ["admin"],
|
roles: ["admin"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
btnText: t("settings.browser-extension"),
|
||||||
|
href: paths.settings.browserExtension(),
|
||||||
|
flex: true,
|
||||||
|
roles: ["admin", "manager"],
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Option
|
<Option
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "Werkzeuge",
|
tools: "Werkzeuge",
|
||||||
"experimental-features": "Experimentelle Funktionen",
|
"experimental-features": "Experimentelle Funktionen",
|
||||||
contact: "Support kontaktieren",
|
contact: "Support kontaktieren",
|
||||||
|
"browser-extension": "Browser-Erweiterung",
|
||||||
},
|
},
|
||||||
|
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "Tools",
|
tools: "Tools",
|
||||||
"experimental-features": "Experimental Features",
|
"experimental-features": "Experimental Features",
|
||||||
contact: "Contact Support",
|
contact: "Contact Support",
|
||||||
|
"browser-extension": "Browser Extension",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Definitions
|
// Page Definitions
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "Herramientas",
|
tools: "Herramientas",
|
||||||
"experimental-features": "Funciones Experimentales",
|
"experimental-features": "Funciones Experimentales",
|
||||||
contact: "Contactar Soporte",
|
contact: "Contactar Soporte",
|
||||||
|
"browser-extension": "Extensión del navegador",
|
||||||
},
|
},
|
||||||
|
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "Outils",
|
tools: "Outils",
|
||||||
"experimental-features": "Fonctionnalités Expérimentales",
|
"experimental-features": "Fonctionnalités Expérimentales",
|
||||||
contact: "Contacter le Support",
|
contact: "Contacter le Support",
|
||||||
|
"browser-extension": "Extension de navigateur",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Definitions
|
// Page Definitions
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "כלים",
|
tools: "כלים",
|
||||||
"experimental-features": "תכונות ניסיוניות",
|
"experimental-features": "תכונות ניסיוניות",
|
||||||
contact: "צור קשר עם התמיכה",
|
contact: "צור קשר עם התמיכה",
|
||||||
|
"browser-extension": "תוסף דפדפן",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Definitions
|
// Page Definitions
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "Strumenti",
|
tools: "Strumenti",
|
||||||
"experimental-features": "Caratteristiche sperimentali",
|
"experimental-features": "Caratteristiche sperimentali",
|
||||||
contact: "Contatta il Supporto",
|
contact: "Contatta il Supporto",
|
||||||
|
"browser-extension": "Estensione del browser",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Definitions
|
// Page Definitions
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "도구",
|
tools: "도구",
|
||||||
"experimental-features": "실험적 기능",
|
"experimental-features": "실험적 기능",
|
||||||
contact: "지원팀 연락",
|
contact: "지원팀 연락",
|
||||||
|
"browser-extension": "브라우저 확장 프로그램",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Definitions
|
// Page Definitions
|
||||||
|
|||||||
@ -38,6 +38,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "Ferramentas",
|
tools: "Ferramentas",
|
||||||
"experimental-features": "Recursos Experimentais",
|
"experimental-features": "Recursos Experimentais",
|
||||||
contact: "Contato com Suporte",
|
contact: "Contato com Suporte",
|
||||||
|
"browser-extension": "Extensão do navegador",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Definitions
|
// Page Definitions
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "Инструменты",
|
tools: "Инструменты",
|
||||||
"experimental-features": "Экспериментальные функции",
|
"experimental-features": "Экспериментальные функции",
|
||||||
contact: "联系支持Связаться с Поддержкой",
|
contact: "联系支持Связаться с Поддержкой",
|
||||||
|
"browser-extension": "Расширение браузера",
|
||||||
},
|
},
|
||||||
|
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@ -39,6 +39,7 @@ const TRANSLATIONS = {
|
|||||||
tools: "工具",
|
tools: "工具",
|
||||||
"experimental-features": "实验功能",
|
"experimental-features": "实验功能",
|
||||||
contact: "联系支持",
|
contact: "联系支持",
|
||||||
|
"browser-extension": "浏览器扩展",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Definitions
|
// 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;
|
if (API_BASE !== "/api") return API_BASE;
|
||||||
return `${window.location.origin}/api`;
|
return `${window.location.origin}/api`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const POPUP_BROWSER_EXTENSION_EVENT = "NEW_BROWSER_EXTENSION_CONNECTION";
|
||||||
|
|||||||
@ -138,6 +138,9 @@ export default {
|
|||||||
embedChats: () => {
|
embedChats: () => {
|
||||||
return `/settings/embed-chats`;
|
return `/settings/embed-chats`;
|
||||||
},
|
},
|
||||||
|
browserExtension: () => {
|
||||||
|
return `/settings/browser-extension`;
|
||||||
|
},
|
||||||
experimental: () => {
|
experimental: () => {
|
||||||
return `/settings/beta-features`;
|
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");
|
} = require("../utils/PasswordRecovery");
|
||||||
const { SlashCommandPresets } = require("../models/slashCommandsPresets");
|
const { SlashCommandPresets } = require("../models/slashCommandsPresets");
|
||||||
const { EncryptionManager } = require("../utils/EncryptionManager");
|
const { EncryptionManager } = require("../utils/EncryptionManager");
|
||||||
|
const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey");
|
||||||
|
|
||||||
function systemEndpoints(app) {
|
function systemEndpoints(app) {
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
@ -495,6 +496,7 @@ function systemEndpoints(app) {
|
|||||||
limit_user_messages: false,
|
limit_user_messages: false,
|
||||||
message_limit: 25,
|
message_limit: 25,
|
||||||
});
|
});
|
||||||
|
await BrowserExtensionApiKey.migrateApiKeysToMultiUser(user.id);
|
||||||
|
|
||||||
await updateENV(
|
await updateENV(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const { workspaceThreadEndpoints } = require("./endpoints/workspaceThreads");
|
|||||||
const { documentEndpoints } = require("./endpoints/document");
|
const { documentEndpoints } = require("./endpoints/document");
|
||||||
const { agentWebsocket } = require("./endpoints/agentWebsocket");
|
const { agentWebsocket } = require("./endpoints/agentWebsocket");
|
||||||
const { experimentalEndpoints } = require("./endpoints/experimental");
|
const { experimentalEndpoints } = require("./endpoints/experimental");
|
||||||
|
const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
|
||||||
const app = express();
|
const app = express();
|
||||||
const apiRouter = express.Router();
|
const apiRouter = express.Router();
|
||||||
const FILE_LIMIT = "3GB";
|
const FILE_LIMIT = "3GB";
|
||||||
@ -62,6 +63,9 @@ developerEndpoints(app, apiRouter);
|
|||||||
// Externally facing embedder endpoints
|
// Externally facing embedder endpoints
|
||||||
embeddedEndpoints(apiRouter);
|
embeddedEndpoints(apiRouter);
|
||||||
|
|
||||||
|
// Externally facing browser extension endpoints
|
||||||
|
browserExtensionEndpoints(apiRouter);
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "development") {
|
if (process.env.NODE_ENV !== "development") {
|
||||||
const { MetaGenerator } = require("./utils/boot/MetaGenerator");
|
const { MetaGenerator } = require("./utils/boot/MetaGenerator");
|
||||||
const IndexPage = new 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[]
|
password_reset_tokens password_reset_tokens[]
|
||||||
workspace_agent_invocations workspace_agent_invocations[]
|
workspace_agent_invocations workspace_agent_invocations[]
|
||||||
slash_command_presets slash_command_presets[]
|
slash_command_presets slash_command_presets[]
|
||||||
|
browser_extension_api_keys browser_extension_api_keys[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model recovery_codes {
|
model recovery_codes {
|
||||||
@ -298,3 +299,14 @@ model document_sync_executions {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
queue document_sync_queues @relation(fields: [queueId], references: [id], onDelete: Cascade)
|
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…
Reference in New Issue
Block a user