From 91f5f94200e3eb7ca808e9e8098594fec52b3d8a Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Tue, 25 Jul 2023 10:37:04 -0700 Subject: [PATCH] [FEATURE] Enable the ability to have multi user instances (#158) * multi user wip * WIP MUM features * invitation mgmt * suspend or unsuspend users * workspace mangement * manage chats * manage chats * add Support for admin system settings for users to delete workspaces and limit chats per user * fix issue ith system var update app to lazy load invite page * cleanup and bug fixes * wrong method * update readme * update readme * update readme * bump version to 0.1.0 --- README.md | 37 +- frontend/package.json | 3 +- frontend/src/App.jsx | 35 +- frontend/src/AuthContext.jsx | 13 +- .../src/components/AdminSidebar/index.jsx | 263 +++++++++++++ frontend/src/components/Modals/Password.jsx | 121 ------ .../Modals/Password/MultiUserAuth.jsx | 92 +++++ .../Modals/Password/SingleUserAuth.jsx | 76 ++++ .../src/components/Modals/Password/index.jsx | 97 +++++ .../components/Modals/Settings/Keys/index.jsx | 172 ++++----- .../Modals/Settings/MultiUserMode/index.jsx | 153 ++++++++ .../Settings/PasswordProtection/index.jsx | 121 +++--- .../src/components/Modals/Settings/index.jsx | 127 +++++-- frontend/src/components/Preloader.jsx | 16 + .../src/components/PrivateRoute/index.jsx | 63 ++++ frontend/src/components/Sidebar/index.jsx | 19 +- .../ChatHistory/HistoricalMessage/index.jsx | 3 +- frontend/src/hooks/usePrefersDarkMode.js | 9 + frontend/src/hooks/useQuery.js | 3 + frontend/src/hooks/useUser.js | 18 + frontend/src/index.css | 55 +-- frontend/src/models/admin.js | 193 ++++++++++ frontend/src/models/invite.js | 27 ++ frontend/src/models/system.js | 12 + frontend/src/models/workspace.js | 6 +- .../src/pages/Admin/Chats/ChatRow/index.jsx | 95 +++++ frontend/src/pages/Admin/Chats/index.jsx | 142 +++++++ .../Admin/Invitations/InviteRow/index.jsx | 80 ++++ .../Invitations/NewInviteModal/index.jsx | 113 ++++++ .../src/pages/Admin/Invitations/index.jsx | 104 ++++++ frontend/src/pages/Admin/System/index.jsx | 155 ++++++++ .../pages/Admin/Users/NewUserModal/index.jsx | 126 +++++++ .../Users/UserRow/EditUserModal/index.jsx | 123 +++++++ .../src/pages/Admin/Users/UserRow/index.jsx | 71 ++++ frontend/src/pages/Admin/Users/index.jsx | 104 ++++++ .../Workspaces/NewWorkspaceModal/index.jsx | 90 +++++ .../EditWorkspaceUsersModal/index.jsx | 155 ++++++++ .../Admin/Workspaces/WorkspaceRow/index.jsx | 63 ++++ frontend/src/pages/Admin/Workspaces/index.jsx | 112 ++++++ .../src/pages/Invite/NewUserModal/index.jsx | 90 +++++ frontend/src/pages/Invite/index.jsx | 53 +++ frontend/src/pages/Main/index.jsx | 4 +- frontend/src/pages/WorkspaceChat/index.jsx | 4 +- frontend/src/utils/constants.js | 3 + frontend/src/utils/paths.js | 17 + frontend/src/utils/request.js | 14 +- frontend/src/utils/session.js | 15 + package.json | 4 +- server/.env.example | 3 +- server/endpoints/admin.js | 348 ++++++++++++++++++ server/endpoints/chat.js | 83 +++-- server/endpoints/invite.js | 63 ++++ server/endpoints/system.js | 307 ++++++++++----- server/endpoints/workspaces.js | 223 ++++++----- server/index.js | 11 +- server/models/documents.js | 2 +- server/models/invite.js | 191 ++++++++++ server/models/systemSettings.js | 122 ++++++ server/models/user.js | 170 +++++++++ server/models/vectors.js | 2 +- server/models/workspace.js | 70 +++- server/models/workspaceChats.js | 77 +++- server/models/workspaceUsers.js | 132 +++++++ server/package.json | 2 + server/utils/chats/commands/reset.js | 4 +- server/utils/chats/index.js | 14 +- server/utils/database/index.js | 10 +- server/utils/http/index.js | 30 +- server/utils/middleware/validatedRequest.js | 40 +- .../utils/vectorDbProviders/chroma/index.js | 5 + server/utils/vectorDbProviders/lance/index.js | 8 + .../utils/vectorDbProviders/pinecone/index.js | 5 + server/yarn.lock | 45 ++- 73 files changed, 4834 insertions(+), 604 deletions(-) create mode 100644 frontend/src/components/AdminSidebar/index.jsx delete mode 100644 frontend/src/components/Modals/Password.jsx create mode 100644 frontend/src/components/Modals/Password/MultiUserAuth.jsx create mode 100644 frontend/src/components/Modals/Password/SingleUserAuth.jsx create mode 100644 frontend/src/components/Modals/Password/index.jsx create mode 100644 frontend/src/components/Modals/Settings/MultiUserMode/index.jsx create mode 100644 frontend/src/components/Preloader.jsx create mode 100644 frontend/src/components/PrivateRoute/index.jsx create mode 100644 frontend/src/hooks/usePrefersDarkMode.js create mode 100644 frontend/src/hooks/useQuery.js create mode 100644 frontend/src/hooks/useUser.js create mode 100644 frontend/src/models/admin.js create mode 100644 frontend/src/models/invite.js create mode 100644 frontend/src/pages/Admin/Chats/ChatRow/index.jsx create mode 100644 frontend/src/pages/Admin/Chats/index.jsx create mode 100644 frontend/src/pages/Admin/Invitations/InviteRow/index.jsx create mode 100644 frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx create mode 100644 frontend/src/pages/Admin/Invitations/index.jsx create mode 100644 frontend/src/pages/Admin/System/index.jsx create mode 100644 frontend/src/pages/Admin/Users/NewUserModal/index.jsx create mode 100644 frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx create mode 100644 frontend/src/pages/Admin/Users/UserRow/index.jsx create mode 100644 frontend/src/pages/Admin/Users/index.jsx create mode 100644 frontend/src/pages/Admin/Workspaces/NewWorkspaceModal/index.jsx create mode 100644 frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx create mode 100644 frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx create mode 100644 frontend/src/pages/Admin/Workspaces/index.jsx create mode 100644 frontend/src/pages/Invite/NewUserModal/index.jsx create mode 100644 frontend/src/pages/Invite/index.jsx create mode 100644 frontend/src/utils/session.js create mode 100644 server/endpoints/admin.js create mode 100644 server/endpoints/invite.js create mode 100644 server/models/invite.js create mode 100644 server/models/systemSettings.js create mode 100644 server/models/user.js create mode 100644 server/models/workspaceUsers.js diff --git a/README.md b/README.md index ceca8dbe..899b96f3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,24 @@ -# 🤖 AnythingLLM: A full-stack personalized AI assistant +

+ 🤖 AnythingLLM: A full-stack personalized AI assistant.
+ A hyper-efficient and open-source document chatbot solution for all. +

-[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/tim.svg?style=social&label=Follow%20%40Timothy%20Carambat)](https://twitter.com/tcarambat) [![](https://dcbadge.vercel.app/api/server/6UyHPeGZAC?compact=true&style=flat)](https://discord.gg/6UyHPeGZAC) +

+ + Twitter + | + + Discord + | + + License + | + + Docs + +

-A full-stack application and tool suite that enables you to turn any document, resource, or piece of content into a piece of data that any LLM can use as reference during chatting. This application runs with very minimal overhead as by default the LLM and vectorDB are hosted remotely, but can be swapped for local instances. Currently this project supports [Pinecone](https://pinecone.io), [ChromaDB](https://trychroma.com) & more for vector storage and [OpenAI](https://openai.com) for LLM/chatting. +A full-stack application that enables you to turn any document, resource, or piece of content into context that any LLM can use as references during chatting. This application allows you to pick and choose which LLM or Vector Database you want to use. Currently this project supports [Pinecone](https://pinecone.io), [ChromaDB](https://trychroma.com) & more for vector storage and [OpenAI](https://openai.com) for LLM/chatting. ![Chatting](/images/screenshots/chat.png) @@ -14,20 +30,21 @@ A full-stack application and tool suite that enables you to turn any document, r ### Product Overview -AnythingLLM aims to be a full-stack application where you can use commercial off-the-shelf LLMs with Long-term-memory solutions or use popular open source LLM and vectorDB solutions. +AnythingLLM aims to be a full-stack application where you can use commercial off-the-shelf LLMs or popular open source LLMs and vectorDB solutions. Anything LLM is a full-stack product that you can run locally as well as host remotely and be able to chat intelligently with any documents you provide it. AnythingLLM divides your documents into objects called `workspaces`. A Workspace functions a lot like a thread, but with the addition of containerization of your documents. Workspaces can share documents, but they do not talk to each other so you can keep your context for each workspace clean. Some cool features of AnythingLLM -- Atomically manage documents to be used in long-term-memory from a simple UI +- Multi-user instance support and oversight +- Atomically manage documents in your vector database from a simple UI - Two chat modes `conversation` and `query`. Conversation retains previous questions and amendments. Query is simple QA against your documents - Each chat response contains a citation that is linked to the original content - Simple technology stack for fast iteration -- Fully capable of being hosted remotely -- "Bring your own LLM" model and vector solution. _still in progress_ -- Extremely efficient cost-saving measures for managing very large documents. you'll never pay to embed a massive document or transcript more than once. 90% more cost effective than other LTM chatbots +- 100% Cloud deployment ready. +- "Bring your own LLM" model. _still in progress - openai support only currently_ +- Extremely efficient cost-saving measures for managing very large documents. You'll never pay to embed a massive document or transcript more than once. 90% more cost effective than other document chatbot solutions. ### Technical Overview This monorepo consists of three main sections: @@ -37,8 +54,8 @@ This monorepo consists of three main sections: ### Requirements - `yarn` and `node` on your machine -- `python` 3.8+ for running scripts in `collector/`. -- access to an LLM like `GPT-3.5`, `GPT-4`*. +- `python` 3.9+ for running scripts in `collector/`. +- access to an LLM like `GPT-3.5`, `GPT-4`. - a [Pinecone.io](https://pinecone.io) free account*. *you can use drop in replacements for these. This is just the easiest to get up and running fast. We support multiple vector database providers. diff --git a/frontend/package.json b/frontend/package.json index a5aad549..41ef5032 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,6 @@ { "name": "anything-llm-frontend", "private": false, - "version": "0.0.1-beta", "type": "module", "license": "MIT", "scripts": { @@ -44,4 +43,4 @@ "tailwindcss": "^3.3.1", "vite": "^4.3.0" } -} +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c29c6693..ca66bdc9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,9 +1,16 @@ import React, { lazy, Suspense } from "react"; import { Routes, Route } from "react-router-dom"; import { ContextWrapper } from "./AuthContext"; +import PrivateRoute, { AdminRoute } from "./components/PrivateRoute"; const Main = lazy(() => import("./pages/Main")); +const InvitePage = lazy(() => import("./pages/Invite")); const WorkspaceChat = lazy(() => import("./pages/WorkspaceChat")); +const AdminUsers = lazy(() => import("./pages/Admin/Users")); +const AdminInvites = lazy(() => import("./pages/Admin/Invitations")); +const AdminWorkspaces = lazy(() => import("./pages/Admin/Workspaces")); +const AdminChats = lazy(() => import("./pages/Admin/Chats")); +const AdminSystem = lazy(() => import("./pages/Admin/System")); export default function App() { return ( @@ -11,7 +18,33 @@ export default function App() { } /> - } /> + } + /> + } /> + + {/* Admin Routes */} + } + /> + } + /> + } + /> + } + /> + } + /> diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx index 4b7f820a..219fbea4 100644 --- a/frontend/src/AuthContext.jsx +++ b/frontend/src/AuthContext.jsx @@ -1,9 +1,10 @@ import React, { useState, createContext } from "react"; +import { AUTH_TOKEN, AUTH_USER } from "./utils/constants"; export const AuthContext = createContext(null); export function ContextWrapper(props) { - const localUser = localStorage.getItem("anythingllm_user"); - const localAuthToken = localStorage.getItem("anythingllm_authToken"); + const localUser = localStorage.getItem(AUTH_USER); + const localAuthToken = localStorage.getItem(AUTH_TOKEN); const [store, setStore] = useState({ user: localUser ? JSON.parse(localUser) : null, authToken: localAuthToken ? localAuthToken : null, @@ -11,13 +12,13 @@ export function ContextWrapper(props) { const [actions] = useState({ updateUser: (user, authToken = "") => { - localStorage.setItem("anythingllm_user", JSON.stringify(user)); - localStorage.setItem("anythingllm_authToken", authToken); + localStorage.setItem(AUTH_USER, JSON.stringify(user)); + localStorage.setItem(AUTH_TOKEN, authToken); setStore({ user, authToken }); }, unsetUser: () => { - localStorage.removeItem("anythingllm_user"); - localStorage.removeItem("anythingllm_authToken"); + localStorage.removeItem(AUTH_USER); + localStorage.removeItem(AUTH_TOKEN); setStore({ user: null, authToken: null }); }, }); diff --git a/frontend/src/components/AdminSidebar/index.jsx b/frontend/src/components/AdminSidebar/index.jsx new file mode 100644 index 00000000..6c8b8f8c --- /dev/null +++ b/frontend/src/components/AdminSidebar/index.jsx @@ -0,0 +1,263 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + BookOpen, + Database, + GitHub, + Mail, + Menu, + MessageSquare, + Settings, + Users, + X, +} from "react-feather"; +import IndexCount from "../Sidebar/IndexCount"; +import LLMStatus from "../Sidebar/LLMStatus"; +import paths from "../../utils/paths"; +import Discord from "../Icons/Discord"; + +export default function AdminSidebar() { + const sidebarRef = useRef(null); + return ( + <> +
+
+ {/* Header Information */} +
+

+ AnythingLLM Admin +

+
+ + + +
+
+ + {/* Primary Body */} +
+
+
+
+
+
+
+
+ + +
+
+ + {/* Footer */} + +
+
+
+
+ + ); +} + +export function SidebarMobileHeader() { + const sidebarRef = useRef(null); + const [showSidebar, setShowSidebar] = useState(false); + const [showBgOverlay, setShowBgOverlay] = useState(false); + + useEffect(() => { + function handleBg() { + if (showSidebar) { + setTimeout(() => { + setShowBgOverlay(true); + }, 300); + } else { + setShowBgOverlay(false); + } + } + handleBg(); + }, [showSidebar]); + + return ( + <> +
+ +

+ AnythingLLM +

+
+
+
setShowSidebar(false)} + /> +
+
+ {/* Header Information */} +
+

+ AnythingLLM Admin +

+
+ + + +
+
+ + {/* Primary Body */} +
+
+
+
+
+
+
+
+ + +
+
+ + {/* Footer */} + +
+
+
+
+
+ + ); +} + +const Option = ({ btnText, icon, href }) => { + const isActive = window.location.pathname === href; + return ( +
+ + {icon} +

+ {btnText} +

+
+
+ ); +}; diff --git a/frontend/src/components/Modals/Password.jsx b/frontend/src/components/Modals/Password.jsx deleted file mode 100644 index 30c628fb..00000000 --- a/frontend/src/components/Modals/Password.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useState, useEffect, useRef } from "react"; -import System from "../../models/system"; - -export default function PasswordModal() { - const [loading, setLoading] = useState(false); - const formEl = useRef(null); - const [error, setError] = useState(null); - const handleLogin = async (e) => { - setError(null); - setLoading(true); - e.preventDefault(); - const data = {}; - - const form = new FormData(formEl.current); - for (var [key, value] of form.entries()) data[key] = value; - const { valid, token, message } = await System.requestToken(data); - if (valid && !!token) { - window.localStorage.setItem("anythingllm_authtoken", token); - window.location.reload(); - } else { - setError(message); - setLoading(false); - } - setLoading(false); - }; - - return ( -
-
-
-
-
-
-

- This workspace is password protected. -

-
-
-
-
- - -
- {error && ( -

- Error: {error} -

- )} -

- You will only have to enter this password once. After - successful login it will be stored in your browser. -

-
-
-
- -
-
-
-
-
- ); -} - -export function usePasswordModal() { - const [requiresAuth, setRequiresAuth] = useState(null); - useEffect(() => { - async function checkAuthReq() { - if (!window) return; - if (import.meta.env.DEV) { - setRequiresAuth(false); - } else { - const currentToken = window.localStorage.getItem( - "anythingllm_authtoken" - ); - const settings = await System.keys(); - const requiresAuth = settings?.RequiresAuth || false; - - // If Auth is disabled - skip check - if (!requiresAuth) { - setRequiresAuth(requiresAuth); - return; - } - - if (!!currentToken) { - const valid = await System.checkAuth(currentToken); - if (!valid) { - setRequiresAuth(true); - window.localStorage.removeItem("anythingllm_authtoken"); - return; - } else { - setRequiresAuth(false); - return; - } - } - setRequiresAuth(true); - } - } - checkAuthReq(); - }, []); - - return { requiresAuth }; -} diff --git a/frontend/src/components/Modals/Password/MultiUserAuth.jsx b/frontend/src/components/Modals/Password/MultiUserAuth.jsx new file mode 100644 index 00000000..630c01ca --- /dev/null +++ b/frontend/src/components/Modals/Password/MultiUserAuth.jsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; +import System from "../../../models/system"; +import { AUTH_TOKEN, AUTH_USER } from "../../../utils/constants"; + +export default function MultiUserAuth() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const handleLogin = async (e) => { + setError(null); + setLoading(true); + e.preventDefault(); + const data = {}; + + const form = new FormData(e.target); + for (var [key, value] of form.entries()) data[key] = value; + const { valid, user, token, message } = await System.requestToken(data); + if (valid && !!token && !!user) { + window.localStorage.setItem(AUTH_USER, JSON.stringify(user)); + window.localStorage.setItem(AUTH_TOKEN, token); + window.location.reload(); + } else { + setError(message); + setLoading(false); + } + setLoading(false); + }; + + return ( +
+
+
+

+ This instance is password protected. +

+
+
+
+
+ + +
+ +
+ + +
+ {error && ( +

+ Error: {error} +

+ )} +

+ You will only have to enter this password once. After successful + login it will be stored in your browser. +

+
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Modals/Password/SingleUserAuth.jsx b/frontend/src/components/Modals/Password/SingleUserAuth.jsx new file mode 100644 index 00000000..c930289a --- /dev/null +++ b/frontend/src/components/Modals/Password/SingleUserAuth.jsx @@ -0,0 +1,76 @@ +import React, { useState } from "react"; +import System from "../../../models/system"; +import { AUTH_TOKEN } from "../../../utils/constants"; + +export default function SingleUserAuth() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const handleLogin = async (e) => { + setError(null); + setLoading(true); + e.preventDefault(); + const data = {}; + + const form = new FormData(e.target); + for (var [key, value] of form.entries()) data[key] = value; + const { valid, token, message } = await System.requestToken(data); + if (valid && !!token) { + window.localStorage.setItem(AUTH_TOKEN, token); + window.location.reload(); + } else { + setError(message); + setLoading(false); + } + setLoading(false); + }; + + return ( +
+
+
+

+ This workspace is password protected. +

+
+
+
+
+ + +
+ {error && ( +

+ Error: {error} +

+ )} +

+ You will only have to enter this password once. After successful + login it will be stored in your browser. +

+
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Modals/Password/index.jsx b/frontend/src/components/Modals/Password/index.jsx new file mode 100644 index 00000000..ec5a0b44 --- /dev/null +++ b/frontend/src/components/Modals/Password/index.jsx @@ -0,0 +1,97 @@ +import React, { useState, useEffect } from "react"; +import System from "../../../models/system"; +import SingleUserAuth from "./SingleUserAuth"; +import MultiUserAuth from "./MultiUserAuth"; +import { AUTH_TOKEN, AUTH_USER } from "../../../utils/constants"; + +export default function PasswordModal({ mode = "single" }) { + return ( +
+
+
+ {mode === "single" ? : } +
+
+ ); +} + +export function usePasswordModal() { + const [auth, setAuth] = useState({ + required: false, + mode: "single", + }); + + useEffect(() => { + async function checkAuthReq() { + if (!window) return; + const settings = await System.keys(); + + if (settings?.MultiUserMode) { + const currentToken = window.localStorage.getItem(AUTH_TOKEN); + if (!!currentToken) { + const valid = await System.checkAuth(currentToken); + if (!valid) { + setAuth({ + requiresAuth: true, + mode: "multi", + }); + window.localStorage.removeItem(AUTH_USER); + window.localStorage.removeItem(AUTH_TOKEN); + return; + } else { + setAuth({ + requiresAuth: false, + mode: "multi", + }); + return; + } + } else { + setAuth({ + requiresAuth: true, + mode: "multi", + }); + return; + } + } else { + // Running token check in single user Auth mode. + // If Single user Auth is disabled - skip check + const requiresAuth = settings?.RequiresAuth || false; + if (!requiresAuth) { + setAuth({ + requiresAuth: false, + mode: "single", + }); + return; + } + + const currentToken = window.localStorage.getItem(AUTH_TOKEN); + if (!!currentToken) { + const valid = await System.checkAuth(currentToken); + if (!valid) { + setAuth({ + requiresAuth: true, + mode: "single", + }); + window.localStorage.removeItem(AUTH_TOKEN); + return; + } else { + setAuth({ + requiresAuth: false, + mode: "single", + }); + return; + } + } else { + setAuth({ + requiresAuth: true, + mode: "single", + }); + return; + } + } + } + checkAuthReq(); + }, []); + + return auth; +} diff --git a/frontend/src/components/Modals/Settings/Keys/index.jsx b/frontend/src/components/Modals/Settings/Keys/index.jsx index 77e1a2f4..36709c39 100644 --- a/frontend/src/components/Modals/Settings/Keys/index.jsx +++ b/frontend/src/components/Modals/Settings/Keys/index.jsx @@ -1,12 +1,12 @@ -import React, { useState, useEffect } from "react"; -import { AlertCircle, Loader, X } from "react-feather"; +import React, { useState } from "react"; +import { AlertCircle, Loader } from "react-feather"; import System from "../../../../models/system"; const noop = () => false; -export default function SystemKeys({ hideModal = noop }) { - const [loading, setLoading] = useState(true); - const [settings, setSettings] = useState({}); - +export default function SystemKeys({ hideModal = noop, user, settings = {} }) { + const canDebug = settings.MultiUserMode + ? settings?.CanDebug && user?.role === "admin" + : settings?.CanDebug; function validSettings(settings) { return ( settings?.OpenAiKey && @@ -20,14 +20,6 @@ export default function SystemKeys({ hideModal = noop }) { : true) ); } - useEffect(() => { - async function fetchKeys() { - const settings = await System.keys(); - setSettings(settings); - setLoading(false); - } - fetchKeys(); - }, []); return (
@@ -40,83 +32,75 @@ export default function SystemKeys({ hideModal = noop }) {

- {loading ? ( -
-

- loading system settings -

-
- ) : ( -
- {!validSettings(settings) && ( -
- -

- Ensure all fields are green before attempting to use - AnythingLLM or it may not function as expected! -

-
- )} - - -
- - {settings?.VectorDB === "pinecone" && ( - <> - - - - - )} - {settings?.VectorDB === "chroma" && ( - <> - - - )} -
- )} +
+ {!validSettings(settings) && ( +
+ +

+ Ensure all fields are green before attempting to use + AnythingLLM or it may not function as expected! +

+
+ )} + + +
+ + {settings?.VectorDB === "pinecone" && ( + <> + + + + + )} + {settings?.VectorDB === "chroma" && ( + <> + + + )} +
)} @@ -269,7 +253,7 @@ function ShowKey({ name, env, value, valid, allowDebug = true }) { onClick={() => setDebug(true)} className="mt-2 text-xs text-slate-300 dark:text-slate-500" > - Debug + Change )}
diff --git a/frontend/src/components/Modals/Settings/MultiUserMode/index.jsx b/frontend/src/components/Modals/Settings/MultiUserMode/index.jsx new file mode 100644 index 00000000..6a8b96e9 --- /dev/null +++ b/frontend/src/components/Modals/Settings/MultiUserMode/index.jsx @@ -0,0 +1,153 @@ +import React, { useState } from "react"; +import System from "../../../../models/system"; +import { AUTH_TOKEN, AUTH_USER } from "../../../../utils/constants"; +import paths from "../../../../utils/paths"; + +const noop = () => false; +export default function MultiUserMode({ hideModal = noop }) { + const [saving, setSaving] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + const [useMultiUserMode, setUseMultiUserMode] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setSuccess(false); + setError(null); + + const form = new FormData(e.target); + const data = { + username: form.get("username"), + password: form.get("password"), + }; + + const { success, error } = await System.setupMultiUser(data); + if (success) { + setSuccess(true); + setSaving(false); + setTimeout(() => { + window.localStorage.removeItem(AUTH_USER); + window.localStorage.removeItem(AUTH_TOKEN); + window.location = paths.admin.users(); + }, 2_000); + return; + } + + setError(error); + setSaving(false); + }; + + return ( +
+
+
+

+ Update your AnythingLLM instance to support multiple concurrent + users with their own workspaces. As the admin you can view all + workspaces and add people into workspaces as well. This change is + not reversible and will permanently alter your AnythingLLM + installation. +

+
+ {(error || success) && ( +
+ {error && ( +
+ {error} +
+ )} + {success && ( +
+ Your page will refresh in a few seconds. +
+ )} +
+ )} +
+
+
+
+ + + +
+
+ {useMultiUserMode && ( + <> +

+ By default, you will be the only admin. As an admin you + will need to create accounts for all new users or admins. + Do not lose your password as only an Admin user can reset + passwords. +

+
+ + +
+
+ + +
+ + + )} +
+
+
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx b/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx index 2b6444ed..387c44bc 100644 --- a/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx +++ b/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx @@ -1,13 +1,16 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import System from "../../../../models/system"; +import { AUTH_TOKEN, AUTH_USER } from "../../../../utils/constants"; const noop = () => false; -export default function PasswordProtection({ hideModal = noop }) { - const [loading, setLoading] = useState(true); +export default function PasswordProtection({ + hideModal = noop, + settings = {}, +}) { const [saving, setSaving] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); - const [usePassword, setUsePassword] = useState(false); + const [usePassword, setUsePassword] = useState(settings?.RequiresAuth); const handleSubmit = async (e) => { e.preventDefault(); @@ -26,7 +29,8 @@ export default function PasswordProtection({ hideModal = noop }) { setSuccess(true); setSaving(false); setTimeout(() => { - window.localStorage.removeItem("anythingllm_authToken"); + window.localStorage.removeItem(AUTH_USER); + window.localStorage.removeItem(AUTH_TOKEN); window.location.reload(); }, 2_000); return; @@ -36,15 +40,6 @@ export default function PasswordProtection({ hideModal = noop }) { setSaving(false); }; - useEffect(() => { - async function fetchKeys() { - const settings = await System.keys(); - setUsePassword(settings?.RequiresAuth); - setLoading(false); - } - fetchKeys(); - }, []); - return (
@@ -69,62 +64,54 @@ export default function PasswordProtection({ hideModal = noop }) {
)}
- {loading ? ( -
-

- loading system settings -

-
- ) : ( -
-
-
- +
+ +
+ -
+
+ {usePassword && ( +
+ setUsePassword(!usePassword)} - checked={usePassword} - className="peer sr-only pointer-events-none" + name="password" + type="text" + className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-stone-600 dark:border-stone-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" + placeholder="Your Instance Password" + minLength={8} + required={true} + autoComplete="off" /> -
- -
-
- {usePassword && ( -
- - -
- )} - -
- -
- )} +
+ )} + +
+ +
- +
- + {loading ? ( +
+
+
+ ) : ( + + )}
); } -function SettingTabs({ selectedTab, changeTab }) { +function SettingTabs({ selectedTab, changeTab, settings, user }) { + if (!settings) { + return ( +
+
+
+ ); + } + return ( -
-
    - } - onClick={changeTab} - /> - } - onClick={changeTab} - /> - } - onClick={changeTab} - /> -
-
+
    + } + onClick={changeTab} + /> + } + onClick={changeTab} + /> + {!settings?.MultiUserMode ? ( + <> + } + onClick={changeTab} + /> + } + onClick={changeTab} + /> + + ) : ( + + )} +
); } @@ -102,6 +150,25 @@ function SettingTab({ ); } +function LogoutTab({ user }) { + if (!user) return null; + + return ( +
  • + +
  • + ); +} + export function useSystemSettingsModal() { const [showing, setShowing] = useState(false); const showModal = () => { diff --git a/frontend/src/components/Preloader.jsx b/frontend/src/components/Preloader.jsx new file mode 100644 index 00000000..728f41bf --- /dev/null +++ b/frontend/src/components/Preloader.jsx @@ -0,0 +1,16 @@ +export default function PreLoader() { + return ( +
    + ); +} + +export function FullScreenLoader() { + return ( +
    +
    +
    + ); +} diff --git a/frontend/src/components/PrivateRoute/index.jsx b/frontend/src/components/PrivateRoute/index.jsx new file mode 100644 index 00000000..33f5c633 --- /dev/null +++ b/frontend/src/components/PrivateRoute/index.jsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { FullScreenLoader } from "../Preloader"; +import validateSessionTokenForUser from "../../utils/session"; +import paths from "../../utils/paths"; +import { AUTH_TOKEN, AUTH_USER } from "../../utils/constants"; +import { userFromStorage } from "../../utils/request"; +import System from "../../models/system"; + +// Used only for Multi-user mode only as we permission specific pages based on auth role. +// When in single user mode we just bypass any authchecks. +function useIsAuthenticated() { + const [isAuthd, setIsAuthed] = useState(null); + + useEffect(() => { + const validateSession = async () => { + const multiUserMode = (await System.keys()).MultiUserMode; + if (!multiUserMode) { + setIsAuthed(true); + return; + } + + const localUser = localStorage.getItem(AUTH_USER); + const localAuthToken = localStorage.getItem(AUTH_TOKEN); + if (!localUser || !localAuthToken) { + setIsAuthed(false); + return; + } + + const isValid = await validateSessionTokenForUser(); + if (!isValid) { + localStorage.removeItem(AUTH_USER); + localStorage.removeItem(AUTH_TOKEN); + setIsAuthed(false); + return; + } + + setIsAuthed(true); + }; + validateSession(); + }, []); + + return isAuthd; +} + +export function AdminRoute({ Component }) { + const authed = useIsAuthenticated(); + if (authed === null) return ; + + const user = userFromStorage(); + return authed && user?.role === "admin" ? ( + + ) : ( + + ); +} + +export default function PrivateRoute({ Component }) { + const authed = useIsAuthenticated(); + if (authed === null) return ; + + return authed ? : ; +} diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx index 6abdd51d..b1287282 100644 --- a/frontend/src/components/Sidebar/index.jsx +++ b/frontend/src/components/Sidebar/index.jsx @@ -6,6 +6,7 @@ import { GitHub, Menu, Plus, + Shield, Tool, } from "react-feather"; import IndexCount from "./IndexCount"; @@ -19,6 +20,7 @@ import NewWorkspaceModal, { import ActiveWorkspaces from "./ActiveWorkspaces"; import paths from "../../utils/paths"; import Discord from "../Icons/Discord"; +import useUser from "../../hooks/useUser"; export default function Sidebar() { const sidebarRef = useRef(null); @@ -47,6 +49,7 @@ export default function Sidebar() { AnythingLLM

    +
    - +
    ); } diff --git a/frontend/src/hooks/usePrefersDarkMode.js b/frontend/src/hooks/usePrefersDarkMode.js new file mode 100644 index 00000000..2c14e810 --- /dev/null +++ b/frontend/src/hooks/usePrefersDarkMode.js @@ -0,0 +1,9 @@ +export default function usePrefersDarkMode() { + if (window?.matchMedia) { + if (window?.matchMedia("(prefers-color-scheme: dark)")?.matches) { + return true; + } + return false; + } + return false; +} diff --git a/frontend/src/hooks/useQuery.js b/frontend/src/hooks/useQuery.js new file mode 100644 index 00000000..2af24ed4 --- /dev/null +++ b/frontend/src/hooks/useQuery.js @@ -0,0 +1,3 @@ +export default function useQuery() { + return new URLSearchParams(window.location.search); +} diff --git a/frontend/src/hooks/useUser.js b/frontend/src/hooks/useUser.js new file mode 100644 index 00000000..c3feb04b --- /dev/null +++ b/frontend/src/hooks/useUser.js @@ -0,0 +1,18 @@ +import { useContext } from "react"; +import { AuthContext } from "../AuthContext"; + +// interface IStore { +// store: { +// user: { +// id: string; +// username: string | null; +// role: string; +// }; +// }; +// } + +export default function useUser() { + const context = useContext(AuthContext); + + return { ...context.store }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 1a721233..225a8d45 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -256,42 +256,25 @@ a { height: 100px !important; } -.blink { - animation: blink 1.5s steps(1) infinite; -} - -@keyframes blink { - 0% { - opacity: 0; - } - - 50% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} - -.background-animate { - background-size: 400%; - -webkit-animation: bgAnimate 10s ease infinite; - -moz-animation: bgAnimate 10s ease infinite; - animation: bgAnimate 10s ease infinite; -} - -@keyframes bgAnimate { - 0%, - 100% { - background-position: 0% 50%; - } - - 50% { - background-position: 100% 50%; - } -} - .grid-loader > circle { fill: #008eff; } + +dialog { + pointer-events: none; + opacity: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +dialog[open] { + opacity: 1; + pointer-events: inherit; +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); +} diff --git a/frontend/src/models/admin.js b/frontend/src/models/admin.js new file mode 100644 index 00000000..8aedf810 --- /dev/null +++ b/frontend/src/models/admin.js @@ -0,0 +1,193 @@ +import { API_BASE } from "../utils/constants"; +import { baseHeaders } from "../utils/request"; + +const Admin = { + // User Management + users: async () => { + return await fetch(`${API_BASE}/admin/users`, { + method: "GET", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .then((res) => res?.users || []) + .catch((e) => { + console.error(e); + return []; + }); + }, + newUser: async (data) => { + return await fetch(`${API_BASE}/admin/users/new`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify(data), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { user: null, error: e.message }; + }); + }, + updateUser: async (userId, data) => { + return await fetch(`${API_BASE}/admin/user/${userId}`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify(data), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + deleteUser: async (userId) => { + return await fetch(`${API_BASE}/admin/user/${userId}`, { + method: "DELETE", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + + // Invitations + invites: async () => { + return await fetch(`${API_BASE}/admin/invites`, { + method: "GET", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .then((res) => res?.invites || []) + .catch((e) => { + console.error(e); + return []; + }); + }, + newInvite: async () => { + return await fetch(`${API_BASE}/admin/invite/new`, { + method: "GET", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { invite: null, error: e.message }; + }); + }, + disableInvite: async (inviteId) => { + return await fetch(`${API_BASE}/admin/invite/${inviteId}`, { + method: "DELETE", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + + // Workspaces Mgmt + workspaces: async () => { + return await fetch(`${API_BASE}/admin/workspaces`, { + method: "GET", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .then((res) => res?.workspaces || []) + .catch((e) => { + console.error(e); + return []; + }); + }, + newWorkspace: async (name) => { + return await fetch(`${API_BASE}/admin/workspaces/new`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ name }), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { workspace: null, error: e.message }; + }); + }, + updateUsersInWorkspace: async (workspaceId, userIds = []) => { + return await fetch( + `${API_BASE}/admin/workspaces/${workspaceId}/update-users`, + { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ userIds }), + } + ) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + deleteWorkspace: async (workspaceId) => { + return await fetch(`${API_BASE}/admin/workspaces/${workspaceId}`, { + method: "DELETE", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + + // Workspace Chats Mgmt + chats: async (offset = 0) => { + return await fetch(`${API_BASE}/admin/workspace-chats`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ offset }), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return []; + }); + }, + deleteChat: async (chatId) => { + return await fetch(`${API_BASE}/admin/workspace-chats/${chatId}`, { + method: "DELETE", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + + // System Preferences + systemPreferences: async () => { + return await fetch(`${API_BASE}/admin/system-preferences`, { + method: "GET", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return null; + }); + }, + updateSystemPreferences: async (updates = {}) => { + return await fetch(`${API_BASE}/admin/system-preferences`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify(updates), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, +}; + +export default Admin; diff --git a/frontend/src/models/invite.js b/frontend/src/models/invite.js new file mode 100644 index 00000000..c5f3e3c4 --- /dev/null +++ b/frontend/src/models/invite.js @@ -0,0 +1,27 @@ +import { API_BASE } from "../utils/constants"; + +const Invite = { + checkInvite: async (inviteCode) => { + return await fetch(`${API_BASE}/invite/${inviteCode}`, { + method: "GET", + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { invite: null, error: e.message }; + }); + }, + acceptInvite: async (inviteCode, newUserInfo = {}) => { + return await fetch(`${API_BASE}/invite/${inviteCode}`, { + method: "POST", + body: JSON.stringify(newUserInfo), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, +}; + +export default Invite; diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 7e5f61bf..43c0013f 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -98,6 +98,18 @@ const System = { return { success: false, error: e.message }; }); }, + setupMultiUser: async (data) => { + return await fetch(`${API_BASE}/system/enable-multi-user`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify(data), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, deleteDocument: async (name, meta) => { return await fetch(`${API_BASE}/system/remove-document`, { method: "DELETE", diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index e8aa0bce..ac61c718 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -49,6 +49,7 @@ const Workspace = { }, chatHistory: async function (slug) { const history = await fetch(`${API_BASE}/workspace/${slug}/chats`, { + method: "GET", headers: baseHeaders(), }) .then((res) => res.json()) @@ -71,7 +72,10 @@ const Workspace = { return chatResult; }, all: async function () { - const workspaces = await fetch(`${API_BASE}/workspaces`) + const workspaces = await fetch(`${API_BASE}/workspaces`, { + method: "GET", + headers: baseHeaders(), + }) .then((res) => res.json()) .then((res) => res.workspaces || []) .catch(() => []); diff --git a/frontend/src/pages/Admin/Chats/ChatRow/index.jsx b/frontend/src/pages/Admin/Chats/ChatRow/index.jsx new file mode 100644 index 00000000..ca0d5c78 --- /dev/null +++ b/frontend/src/pages/Admin/Chats/ChatRow/index.jsx @@ -0,0 +1,95 @@ +import { useRef } from "react"; +import Admin from "../../../../models/admin"; +import truncate from "truncate"; +import { X } from "react-feather"; + +export default function ChatRow({ chat }) { + const rowRef = useRef(null); + const handleDelete = async () => { + if ( + !window.confirm( + `Are you sure you want to delete this chat?\n\nThis action is irreversible.` + ) + ) + return false; + rowRef?.current?.remove(); + await Admin.deleteChat(chat.id); + }; + + return ( + <> + + + {chat.id} + + + {chat.user?.username} + + {chat.workspace?.name} + { + document.getElementById(`chat-${chat.id}-prompt`)?.showModal(); + }} + className="px-6 py-4 hover:dark:bg-stone-700 hover:bg-gray-100 cursor-pointer" + > + {truncate(chat.prompt, 40)} + + { + document.getElementById(`chat-${chat.id}-response`)?.showModal(); + }} + className="px-6 py-4 hover:dark:bg-stone-600 hover:bg-gray-100 cursor-pointer" + > + {truncate(JSON.parse(chat.response)?.text, 40)} + + {chat.createdAt} + + + + + + + + ); +} + +function hideModal(modalName) { + document.getElementById(modalName)?.close(); +} + +const TextPreview = ({ text, modalName }) => { + return ( + +
    +
    +
    +

    + Viewing Text +

    + +
    +
    +
    +              {text}
    +            
    +
    +
    +
    +
    + ); +}; diff --git a/frontend/src/pages/Admin/Chats/index.jsx b/frontend/src/pages/Admin/Chats/index.jsx new file mode 100644 index 00000000..8c439bb7 --- /dev/null +++ b/frontend/src/pages/Admin/Chats/index.jsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from "react"; +import Sidebar, { SidebarMobileHeader } from "../../../components/AdminSidebar"; +import { isMobile } from "react-device-detect"; +import * as Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import usePrefersDarkMode from "../../../hooks/usePrefersDarkMode"; +import Admin from "../../../models/admin"; +import useQuery from "../../../hooks/useQuery"; +import ChatRow from "./ChatRow"; + +export default function AdminChats() { + return ( +
    + {!isMobile && } +
    + {isMobile && } +
    +
    +
    +

    + Workspace Chats +

    +
    +

    + These are all the recorded chats and messages that have been sent + by users ordered by their creation date. +

    +
    + +
    +
    +
    + ); +} + +function ChatsContainer() { + const query = useQuery(); + const darkMode = usePrefersDarkMode(); + const [loading, setLoading] = useState(true); + const [chats, setChats] = useState([]); + const [offset, setOffset] = useState(Number(query.get("offset") || 0)); + const [canNext, setCanNext] = useState(false); + + const handlePrevious = () => { + if (chats.length === 0) { + setOffset(0); + return; + } + + const chat = chats.at(-1); + if (chat.id - 20 <= 0) { + setOffset(0); + return; + } + + setOffset(chat.id - 1); + }; + const handleNext = () => { + setOffset(chats[0].id + 1); + }; + + useEffect(() => { + async function fetchChats() { + const { chats: _chats, hasPages = false } = await Admin.chats(offset); + setChats(_chats); + setCanNext(hasPages); + setLoading(false); + } + fetchChats(); + }, [offset]); + + if (loading) { + return ( + + ); + } + + return ( + <> + + + + + + + + + + + + + + {chats.map((chat) => ( + + ))} + +
    + Id + + Sent By + + Workspace + + Prompt + + Response + + Sent At + + Actions +
    +
    + + +
    + + ); +} diff --git a/frontend/src/pages/Admin/Invitations/InviteRow/index.jsx b/frontend/src/pages/Admin/Invitations/InviteRow/index.jsx new file mode 100644 index 00000000..4c6a643e --- /dev/null +++ b/frontend/src/pages/Admin/Invitations/InviteRow/index.jsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from "react"; +import { titleCase } from "text-case"; +import Admin from "../../../../models/admin"; + +export default function InviteRow({ invite }) { + const rowRef = useRef(null); + const [status, setStatus] = useState(invite.status); + const [copied, setCopied] = useState(false); + const handleDelete = async () => { + if ( + !window.confirm( + `Are you sure you want to deactivate this invite?\nAfter you do this it will not longer be useable.\n\nThis action is irreversible.` + ) + ) + return false; + if (rowRef?.current) { + rowRef.current.children[0].innerText = "Disabled"; + } + setStatus("disabled"); + await Admin.disableInvite(invite.id); + }; + const copyInviteLink = () => { + if (!invite) return false; + window.navigator.clipboard.writeText( + `${window.location.origin}/accept-invite/${invite.code}` + ); + setCopied(true); + }; + + useEffect(() => { + function resetStatus() { + if (!copied) return false; + setTimeout(() => { + setCopied(false); + }, 3000); + } + resetStatus(); + }, [copied]); + + return ( + <> + + + {titleCase(status)} + + + {invite.claimedBy + ? invite.claimedBy?.username || "deleted user" + : "--"} + + + {invite.createdBy?.username || "deleted user"} + + {invite.createdAt} + + {status === "pending" && ( + <> + + + + )} + + + + ); +} diff --git a/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx b/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx new file mode 100644 index 00000000..6da534e2 --- /dev/null +++ b/frontend/src/pages/Admin/Invitations/NewInviteModal/index.jsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from "react"; +import { X } from "react-feather"; +import Admin from "../../../../models/admin"; +const DIALOG_ID = `new-invite-modal`; + +function hideModal() { + document.getElementById(DIALOG_ID)?.close(); +} + +export const NewInviteModalId = DIALOG_ID; +export default function NewInviteModal() { + const [invite, setInvite] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const handleCreate = async (e) => { + setError(null); + e.preventDefault(); + const { invite: newInvite, error } = await Admin.newInvite(); + if (!!newInvite) setInvite(newInvite); + setError(error); + }; + const copyInviteLink = () => { + if (!invite) return false; + window.navigator.clipboard.writeText( + `${window.location.origin}/accept-invite/${invite.code}` + ); + setCopied(true); + }; + useEffect(() => { + function resetStatus() { + if (!copied) return false; + setTimeout(() => { + setCopied(false); + }, 3000); + } + resetStatus(); + }, [copied]); + + return ( + +
    +
    +
    +

    + Create new invite +

    + +
    +
    +
    +
    + {error && ( +

    + Error: {error} +

    + )} + {invite && ( + + )} +

    + After creation you will be able to copy the invite and send it + to a new user where they can create an account as a default + user. +

    +
    +
    +
    + {!invite ? ( + <> + + + + ) : ( + + )} +
    +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Admin/Invitations/index.jsx b/frontend/src/pages/Admin/Invitations/index.jsx new file mode 100644 index 00000000..65127590 --- /dev/null +++ b/frontend/src/pages/Admin/Invitations/index.jsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import Sidebar, { SidebarMobileHeader } from "../../../components/AdminSidebar"; +import { isMobile } from "react-device-detect"; +import * as Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { Mail } from "react-feather"; +import usePrefersDarkMode from "../../../hooks/usePrefersDarkMode"; +import Admin from "../../../models/admin"; +import InviteRow from "./InviteRow"; +import NewInviteModal, { NewInviteModalId } from "./NewInviteModal"; + +export default function AdminInvites() { + return ( +
    + {!isMobile && } +
    + {isMobile && } +
    +
    +
    +

    + Invitations +

    + +
    +

    + Create invitation links for people in your organization to accept + and sign up with. Invitations can only be used by a single user. +

    +
    + +
    + +
    +
    + ); +} + +function InvitationsContainer() { + const darkMode = usePrefersDarkMode(); + const [loading, setLoading] = useState(true); + const [invites, setInvites] = useState([]); + useEffect(() => { + async function fetchInvites() { + const _invites = await Admin.invites(); + setInvites(_invites); + setLoading(false); + } + fetchInvites(); + }, []); + + if (loading) { + return ( + + ); + } + + return ( + + + + + + + + + + + + {invites.map((invite) => ( + + ))} + +
    + Status + + Accepted By + + Created By + + Created + + Actions +
    + ); +} diff --git a/frontend/src/pages/Admin/System/index.jsx b/frontend/src/pages/Admin/System/index.jsx new file mode 100644 index 00000000..58874b90 --- /dev/null +++ b/frontend/src/pages/Admin/System/index.jsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import Sidebar, { SidebarMobileHeader } from "../../../components/AdminSidebar"; +import { isMobile } from "react-device-detect"; +import Admin from "../../../models/admin"; + +export default function AdminSystem() { + const [saving, setSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [canDelete, setCanDelete] = useState(false); + const [messageLimit, setMessageLimit] = useState({ + enabled: false, + limit: 10, + }); + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + await Admin.updateSystemPreferences({ + users_can_delete_workspaces: canDelete, + limit_user_messages: messageLimit.enabled, + message_limit: messageLimit.limit, + }); + setSaving(false); + setHasChanges(false); + }; + + useEffect(() => { + async function fetchSettings() { + const { settings } = await Admin.systemPreferences(); + if (!settings) return; + setCanDelete(settings?.users_can_delete_workspaces); + setMessageLimit({ + enabled: settings.limit_user_messages, + limit: settings.message_limit, + }); + } + fetchSettings(); + }, []); + + return ( +
    + {!isMobile && } +
    + {isMobile && } +
    setHasChanges(true)} + className="flex w-full" + > +
    +
    +
    +

    + System Preferences +

    + {hasChanges && ( + + )} +
    +

    + These are the overall settings and configurations of your + instance. +

    +
    + +
    +
    + +

    + allow non-admin users to delete workspaces that they are a + part of. This would delete the workspace for everyone. +

    +
    + +
    + +
    +
    + +

    + Restrict non-admin users to a number of successful queries or + chats within a 24 hour window. Enable this to prevent users + from running up OpenAI costs. +

    +
    + +
    + {messageLimit.enabled && ( +
    + +
    + e.target.blur()} + onChange={(e) => { + setMessageLimit({ + enabled: true, + limit: Number(e?.target?.value || 0), + }); + }} + value={messageLimit.limit} + min={1} + max={300} + className="w-1/3 my-2 rounded-lg border border-stroke bg-transparent py-4 pl-6 pr-10 text-gray-800 dark:text-slate-200 outline-none focus:border-primary focus-visible:shadow-none dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary" + /> +
    +
    + )} +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Admin/Users/NewUserModal/index.jsx b/frontend/src/pages/Admin/Users/NewUserModal/index.jsx new file mode 100644 index 00000000..ac957d73 --- /dev/null +++ b/frontend/src/pages/Admin/Users/NewUserModal/index.jsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { X } from "react-feather"; +import Admin from "../../../../models/admin"; +const DIALOG_ID = `new-user-modal`; + +function hideModal() { + document.getElementById(DIALOG_ID)?.close(); +} + +export const NewUserModalId = DIALOG_ID; +export default function NewUserModal() { + const [error, setError] = useState(null); + const handleCreate = async (e) => { + setError(null); + e.preventDefault(); + const data = {}; + const form = new FormData(e.target); + for (var [key, value] of form.entries()) data[key] = value; + const { user, error } = await Admin.newUser(data); + if (!!user) window.location.reload(); + setError(error); + }; + + return ( + +
    +
    +
    +

    + Add user to instance +

    + +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + {error && ( +

    + Error: {error} +

    + )} +

    + After creating a user they will need to login with their + initial login to get access. +

    +
    +
    +
    + + +
    +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx new file mode 100644 index 00000000..cdab66a6 --- /dev/null +++ b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx @@ -0,0 +1,123 @@ +import React, { useState } from "react"; +import { X } from "react-feather"; +import Admin from "../../../../../models/admin"; + +export const EditUserModalId = (user) => `edit-user-${user.id}-modal`; +export default function EditUserModal({ user }) { + const [error, setError] = useState(null); + const hideModal = () => { + document.getElementById(EditUserModalId(user)).close(); + }; + const handleUpdate = async (e) => { + setError(null); + e.preventDefault(); + const data = {}; + const form = new FormData(e.target); + for (var [key, value] of form.entries()) { + if (!value || value === null) continue; + data[key] = value; + } + const { success, error } = await Admin.updateUser(user.id, data); + if (success) window.location.reload(); + setError(error); + }; + + return ( + +
    +
    +
    +

    + Edit {user.username} +

    + +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + {error && ( +

    + Error: {error} +

    + )} +
    +
    +
    + + +
    +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Admin/Users/UserRow/index.jsx b/frontend/src/pages/Admin/Users/UserRow/index.jsx new file mode 100644 index 00000000..df7fbc4e --- /dev/null +++ b/frontend/src/pages/Admin/Users/UserRow/index.jsx @@ -0,0 +1,71 @@ +import { useRef, useState } from "react"; +import { titleCase } from "text-case"; +import Admin from "../../../../models/admin"; +import EditUserModal, { EditUserModalId } from "./EditUserModal"; + +export default function UserRow({ currUser, user }) { + const rowRef = useRef(null); + const [suspended, setSuspended] = useState(user.suspended === 1); + const handleSuspend = async () => { + if ( + !window.confirm( + `Are you sure you want to suspend ${user.username}?\nAfter you do this they will be logged out and unable to log back into this instance of AnythingLLM until unsuspended by an admin.` + ) + ) + return false; + setSuspended(!suspended); + await Admin.updateUser(user.id, { suspended: suspended ? 0 : 1 }); + }; + const handleDelete = async () => { + if ( + !window.confirm( + `Are you sure you want to delete ${user.username}?\nAfter you do this they will be logged out and unable to use this instance of AnythingLLM.\n\nThis action is irreversible.` + ) + ) + return false; + rowRef?.current?.remove(); + await Admin.deleteUser(user.id); + }; + + return ( + <> + + + {user.username} + + {titleCase(user.role)} + {user.createdAt} + + + {currUser.id !== user.id && ( + <> + + + + )} + + + + + ); +} diff --git a/frontend/src/pages/Admin/Users/index.jsx b/frontend/src/pages/Admin/Users/index.jsx new file mode 100644 index 00000000..b7873bcb --- /dev/null +++ b/frontend/src/pages/Admin/Users/index.jsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import Sidebar, { SidebarMobileHeader } from "../../../components/AdminSidebar"; +import { isMobile } from "react-device-detect"; +import * as Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { UserPlus } from "react-feather"; +import usePrefersDarkMode from "../../../hooks/usePrefersDarkMode"; +import Admin from "../../../models/admin"; +import UserRow from "./UserRow"; +import useUser from "../../../hooks/useUser"; +import NewUserModal, { NewUserModalId } from "./NewUserModal"; + +export default function AdminUsers() { + return ( +
    + {!isMobile && } +
    + {isMobile && } +
    +
    +
    +

    + Instance users +

    + +
    +

    + These are all the accounts which have an account on this instance. + Removing an account will instantly remove their access to this + instance. +

    +
    + +
    + +
    +
    + ); +} + +function UsersContainer() { + const { user: currUser } = useUser(); + const darkMode = usePrefersDarkMode(); + const [loading, setLoading] = useState(true); + const [users, setUsers] = useState([]); + useEffect(() => { + async function fetchUsers() { + const _users = await Admin.users(); + setUsers(_users); + setLoading(false); + } + fetchUsers(); + }, []); + + if (loading) { + return ( + + ); + } + + return ( + + + + + + + + + + + {users.map((user) => ( + + ))} + +
    + Username + + Role + + Created On + + Actions +
    + ); +} diff --git a/frontend/src/pages/Admin/Workspaces/NewWorkspaceModal/index.jsx b/frontend/src/pages/Admin/Workspaces/NewWorkspaceModal/index.jsx new file mode 100644 index 00000000..bc38c191 --- /dev/null +++ b/frontend/src/pages/Admin/Workspaces/NewWorkspaceModal/index.jsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { X } from "react-feather"; +import Admin from "../../../../models/admin"; +const DIALOG_ID = `new-workspace-modal`; + +function hideModal() { + document.getElementById(DIALOG_ID)?.close(); +} + +export const NewWorkspaceModalId = DIALOG_ID; +export default function NewWorkspaceModal() { + const [error, setError] = useState(null); + const handleCreate = async (e) => { + setError(null); + e.preventDefault(); + const form = new FormData(e.target); + const { workspace, error } = await Admin.newWorkspace(form.get("name")); + if (!!workspace) window.location.reload(); + setError(error); + }; + + return ( + +
    +
    +
    +

    + Add workspace to Instance +

    + +
    +
    +
    +
    +
    + + +
    + {error && ( +

    + Error: {error} +

    + )} +

    + After creating this workspace only admins will be able to see + it. You can add users after it has been created. +

    +
    +
    +
    + + +
    +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx new file mode 100644 index 00000000..c363dbed --- /dev/null +++ b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx @@ -0,0 +1,155 @@ +import React, { useState } from "react"; +import { X } from "react-feather"; +import Admin from "../../../../../models/admin"; +import { titleCase } from "text-case"; + +export const EditWorkspaceUsersModalId = (workspace) => + `edit-workspace-${workspace.id}-modal`; +export default function EditWorkspaceUsersModal({ workspace, users }) { + const [error, setError] = useState(null); + const hideModal = () => { + document.getElementById(EditWorkspaceUsersModalId(workspace)).close(); + }; + const handleUpdate = async (e) => { + setError(null); + e.preventDefault(); + const data = { + userIds: [], + }; + const form = new FormData(e.target); + for (var [key, value] of form.entries()) { + if (key.includes("user-") && value === "yes") { + const [_, id] = key.split(`-`); + data.userIds.push(+id); + } + } + const { success, error } = await Admin.updateUsersInWorkspace( + workspace.id, + data.userIds + ); + if (success) window.location.reload(); + setError(error); + }; + + return ( + +
    +
    +
    +

    + Edit {workspace.name} +

    + +
    +
    +
    +
    + {users + .filter((user) => user.role !== "admin") + .map((user) => { + return ( +
    { + document + .getElementById( + `workspace-${workspace.id}-user-${user.id}` + ) + ?.click(); + }} + > + + +
    + ); + })} +
    + + +
    + {error && ( +

    + Error: {error} +

    + )} +
    +
    +
    + + +
    +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx new file mode 100644 index 00000000..762e2089 --- /dev/null +++ b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx @@ -0,0 +1,63 @@ +import { useRef } from "react"; +import Admin from "../../../../models/admin"; +import paths from "../../../../utils/paths"; +import EditWorkspaceUsersModal, { + EditWorkspaceUsersModalId, +} from "./EditWorkspaceUsersModal"; + +export default function WorkspaceRow({ workspace, users }) { + const rowRef = useRef(null); + const handleDelete = async () => { + if ( + !window.confirm( + `Are you sure you want to delete ${workspace.name}?\nAfter you do this it will be unavailable in this instance of AnythingLLM.\n\nThis action is irreversible.` + ) + ) + return false; + rowRef?.current?.remove(); + await Admin.deleteWorkspace(workspace.id); + }; + + return ( + <> + + + {workspace.name} + + + + {workspace.slug} + + + {workspace.userIds?.length} + {workspace.createdAt} + + + + + + + + ); +} diff --git a/frontend/src/pages/Admin/Workspaces/index.jsx b/frontend/src/pages/Admin/Workspaces/index.jsx new file mode 100644 index 00000000..ff3cb45a --- /dev/null +++ b/frontend/src/pages/Admin/Workspaces/index.jsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import Sidebar, { SidebarMobileHeader } from "../../../components/AdminSidebar"; +import { isMobile } from "react-device-detect"; +import * as Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { BookOpen } from "react-feather"; +import usePrefersDarkMode from "../../../hooks/usePrefersDarkMode"; +import Admin from "../../../models/admin"; +import WorkspaceRow from "./WorkspaceRow"; +import NewWorkspaceModal, { NewWorkspaceModalId } from "./NewWorkspaceModal"; + +export default function AdminWorkspaces() { + return ( +
    + {!isMobile && } +
    + {isMobile && } +
    +
    +
    +

    + Instance workspaces +

    + +
    +

    + These are all the workspaces that exist on this instance. Removing + a workspace will delete all of it's associated chats and settings. +

    +
    + +
    + +
    +
    + ); +} + +function WorkspacesContainer() { + const darkMode = usePrefersDarkMode(); + const [loading, setLoading] = useState(true); + const [users, setUsers] = useState([]); + const [workspaces, setWorkspaces] = useState([]); + + useEffect(() => { + async function fetchData() { + const _users = await Admin.users(); + const _workspaces = await Admin.workspaces(); + setUsers(_users); + setWorkspaces(_workspaces); + setLoading(false); + } + fetchData(); + }, []); + + if (loading) { + return ( + + ); + } + + return ( + + + + + + + + + + + + {workspaces.map((workspace) => ( + + ))} + +
    + Name + + Link + + Users + + Created On + + Actions +
    + ); +} diff --git a/frontend/src/pages/Invite/NewUserModal/index.jsx b/frontend/src/pages/Invite/NewUserModal/index.jsx new file mode 100644 index 00000000..ae3fb6ed --- /dev/null +++ b/frontend/src/pages/Invite/NewUserModal/index.jsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import Invite from "../../../models/invite"; +import paths from "../../../utils/paths"; +import { useParams } from "react-router-dom"; + +export default function NewUserModal() { + const { code } = useParams(); + const [error, setError] = useState(null); + const handleCreate = async (e) => { + setError(null); + e.preventDefault(); + const data = {}; + const form = new FormData(e.target); + for (var [key, value] of form.entries()) data[key] = value; + const { success, error } = await Invite.acceptInvite(code, data); + if (!!success) window.location.replace(paths.home()); + setError(error); + }; + + return ( + +
    +
    +
    +

    + Create a new account +

    +
    +
    +
    +
    +
    + + +
    +
    + + +
    + {error && ( +

    + Error: {error} +

    + )} +

    + After creating your account you will be able to login with + these credentials and start using workspaces. +

    +
    +
    +
    + +
    +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/Invite/index.jsx b/frontend/src/pages/Invite/index.jsx new file mode 100644 index 00000000..2818fd11 --- /dev/null +++ b/frontend/src/pages/Invite/index.jsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { FullScreenLoader } from "../../components/Preloader"; +import Invite from "../../models/invite"; +import NewUserModal from "./NewUserModal"; + +export default function InvitePage() { + const { code } = useParams(); + const [result, setResult] = useState({ + status: "loading", + message: null, + }); + + useEffect(() => { + async function checkInvite() { + if (!code) { + setResult({ + status: "invalid", + message: "No invite code provided.", + }); + return; + } + const { invite, error } = await Invite.checkInvite(code); + setResult({ + status: invite ? "valid" : "invalid", + message: error, + }); + } + checkInvite(); + }, []); + + if (result.status === "loading") { + return ( +
    + +
    + ); + } + + if (result.status === "invalid") { + return ( +
    +

    {result.message}

    +
    + ); + } + + return ( +
    + +
    + ); +} diff --git a/frontend/src/pages/Main/index.jsx b/frontend/src/pages/Main/index.jsx index 29b5ee3e..d6588181 100644 --- a/frontend/src/pages/Main/index.jsx +++ b/frontend/src/pages/Main/index.jsx @@ -9,11 +9,11 @@ import PasswordModal, { import { isMobile } from "react-device-detect"; export default function Main() { - const { requiresAuth } = usePasswordModal(); + const { requiresAuth, mode } = usePasswordModal(); if (requiresAuth === null || requiresAuth) { return ( <> - {requiresAuth && } + {requiresAuth && }
    {!isMobile && } diff --git a/frontend/src/pages/WorkspaceChat/index.jsx b/frontend/src/pages/WorkspaceChat/index.jsx index 1eec9041..612b51a0 100644 --- a/frontend/src/pages/WorkspaceChat/index.jsx +++ b/frontend/src/pages/WorkspaceChat/index.jsx @@ -11,11 +11,11 @@ import PasswordModal, { import { isMobile } from "react-device-detect"; export default function WorkspaceChat() { - const { requiresAuth } = usePasswordModal(); + const { requiresAuth, mode } = usePasswordModal(); if (requiresAuth === null || requiresAuth) { return ( <> - {requiresAuth && } + {requiresAuth && }
    {!isMobile && } diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 53995e6a..e96e6836 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -1,2 +1,5 @@ export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:3001/api"; + +export const AUTH_USER = "anythingllm_user"; +export const AUTH_TOKEN = "anythingllm_authToken"; diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js index 03ff7b54..57eee6e1 100644 --- a/frontend/src/utils/paths.js +++ b/frontend/src/utils/paths.js @@ -27,4 +27,21 @@ export default { exports: () => { return `${API_BASE.replace("/api", "")}/system/data-exports`; }, + admin: { + system: () => { + return `/admin/system-preferences`; + }, + users: () => { + return `/admin/users`; + }, + invites: () => { + return `/admin/invites`; + }, + workspaces: () => { + return `/admin/workspaces`; + }, + chats: () => { + return "/admin/workspace-chats"; + }, + }, }; diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js index 4de7681e..271c97b2 100644 --- a/frontend/src/utils/request.js +++ b/frontend/src/utils/request.js @@ -1,8 +1,18 @@ +import { AUTH_TOKEN, AUTH_USER } from "./constants"; + // Sets up the base headers for all authenticated requests so that we are able to prevent // basic spoofing since a valid token is required and that cannot be spoofed +export function userFromStorage() { + try { + const userString = window.localStorage.getItem(AUTH_USER); + if (!userString) return null; + return JSON.parse(userString); + } catch {} + return {}; +} + export function baseHeaders(providedToken = null) { - const token = - providedToken || window.localStorage.getItem("anythingllm_authtoken"); + const token = providedToken || window.localStorage.getItem(AUTH_TOKEN); return { Authorization: token ? `Bearer ${token}` : null, }; diff --git a/frontend/src/utils/session.js b/frontend/src/utils/session.js new file mode 100644 index 00000000..27228e48 --- /dev/null +++ b/frontend/src/utils/session.js @@ -0,0 +1,15 @@ +import { API_BASE } from "./constants"; +import { baseHeaders } from "./request"; + +// Checks current localstorage and validates the session based on that. +export default async function validateSessionTokenForUser() { + const isValidSession = await fetch(`${API_BASE}/system/check-token`, { + method: "GET", + cache: "default", + headers: baseHeaders(), + }) + .then((res) => res.status === 200) + .catch(() => false); + + return isValidSession; +} diff --git a/package.json b/package.json index 7df9953f..12d46fe3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "anything-llm", - "version": "0.0.1-beta", - "description": "Turn anything into a chattable document through a simple UI", + "version": "0.1.0", + "description": "The best solution for turning private documents into a chat bot using off-the-shelf tools and commercially viable AI technologies.", "main": "index.js", "author": "Timothy Carambat (Mintplex Labs)", "license": "MIT", diff --git a/server/.env.example b/server/.env.example index a74cec57..f671f78d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -16,8 +16,9 @@ PINECONE_INDEX= # Enable all below if you are using vector database: LanceDB. # VECTOR_DB="lancedb" +JWT_SECRET="my-random-string-for-seeding" # Please generate random string at least 12 chars long. + # CLOUD DEPLOYMENT VARIRABLES ONLY # AUTH_TOKEN="hunter2" # This is the password to your application if remote hosting. -# JWT_SECRET="my-random-string-for-seeding" # Only needed if AUTH_TOKEN is set. Please generate random string at least 12 chars long. # STORAGE_DIR= # absolute filesystem path with no trailing slash # NO_DEBUG="true" \ No newline at end of file diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js new file mode 100644 index 00000000..b58dc0c1 --- /dev/null +++ b/server/endpoints/admin.js @@ -0,0 +1,348 @@ +const { Document } = require("../models/documents"); +const { Invite } = require("../models/invite"); +const { SystemSettings } = require("../models/systemSettings"); +const { User } = require("../models/user"); +const { DocumentVectors } = require("../models/vectors"); +const { Workspace } = require("../models/workspace"); +const { WorkspaceChats } = require("../models/workspaceChats"); +const { getVectorDbClass } = require("../utils/helpers"); +const { userFromSession, reqBody } = require("../utils/http"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); + +function adminEndpoints(app) { + if (!app) return; + + app.get("/admin/users", [validatedRequest], async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + const users = (await User.where()).map((user) => { + const { password, ...rest } = user; + return rest; + }); + response.status(200).json({ users }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.post( + "/admin/users/new", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const newUserParams = reqBody(request); + const { user: newUser, error } = await User.create(newUserParams); + response.status(200).json({ user: newUser, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post("/admin/user/:id", [validatedRequest], async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const { id } = request.params; + const updates = reqBody(request); + const { success, error } = await User.update(id, updates); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.delete( + "/admin/user/:id", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + const { id } = request.params; + await User.delete(`id = ${id}`); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get("/admin/invites", [validatedRequest], async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const invites = await Invite.whereWithUsers(); + response.status(200).json({ invites }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.get( + "/admin/invite/new", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const { invite, error } = await Invite.create(user.id); + response.status(200).json({ invite, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/admin/invite/:id", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const { id } = request.params; + const { success, error } = await Invite.deactivate(id); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/admin/workspaces", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + const workspaces = await Workspace.whereWithUsers(); + response.status(200).json({ workspaces }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/workspaces/new", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + const { name } = reqBody(request); + const { workspace, message: error } = await Workspace.new( + name, + user.id + ); + response.status(200).json({ workspace, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/workspaces/:workspaceId/update-users", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const { workspaceId } = request.params; + const { userIds } = reqBody(request); + const { success, error } = await Workspace.updateUsers( + workspaceId, + userIds + ); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/admin/workspaces/:id", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const { id } = request.params; + const VectorDb = getVectorDbClass(); + const workspace = Workspace.get(`id = ${id}`); + if (!workspace) { + response.sendStatus(404).end(); + return; + } + + await Workspace.delete(`id = ${id}`); + await DocumentVectors.deleteForWorkspace(id); + await Document.delete(`workspaceId = ${Number(id)}`); + await WorkspaceChats.delete(`workspaceId = ${Number(id)}`); + try { + await VectorDb["delete-namespace"]({ namespace: workspace.slug }); + } catch (e) { + console.error(e.message); + } + + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/workspace-chats", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + const { offset = 0 } = reqBody(request); + const chats = await WorkspaceChats.whereWithData(`id >= ${offset}`, 20); + const hasPages = (await WorkspaceChats.count()) > 20; + response.status(200).json({ chats: chats.reverse(), hasPages }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/admin/workspace-chats/:id", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const { id } = request.params; + await WorkspaceChats.delete(`id = ${id}`); + response.status(200).json({ success, error }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.get( + "/admin/system-preferences", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const settings = { + users_can_delete_workspaces: + (await SystemSettings.get(`label = 'users_can_delete_workspaces'`)) + ?.value === "true", + limit_user_messages: + (await SystemSettings.get(`label = 'limit_user_messages'`)) + ?.value === "true", + message_limit: + Number( + (await SystemSettings.get(`label = 'message_limit'`))?.value + ) || 10, + }; + response.status(200).json({ settings }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/admin/system-preferences", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + if (!user || user?.role !== "admin") { + response.sendStatus(401).end(); + return; + } + + const updates = reqBody(request); + await SystemSettings.updateSettings(updates); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); +} + +module.exports = { adminEndpoints }; diff --git a/server/endpoints/chat.js b/server/endpoints/chat.js index 7c303643..0a9544d5 100644 --- a/server/endpoints/chat.js +++ b/server/endpoints/chat.js @@ -1,34 +1,73 @@ const { v4: uuidv4 } = require("uuid"); -const { reqBody } = require("../utils/http"); +const { reqBody, userFromSession, multiUserMode } = require("../utils/http"); const { Workspace } = require("../models/workspace"); const { chatWithWorkspace } = require("../utils/chats"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { WorkspaceChats } = require("../models/workspaceChats"); +const { SystemSettings } = require("../models/systemSettings"); function chatEndpoints(app) { if (!app) return; - app.post("/workspace/:slug/chat", async (request, response) => { - try { - const { slug } = request.params; - const { message, mode = "query" } = reqBody(request); - const workspace = await Workspace.get(`slug = '${slug}'`); - if (!workspace) { - response.sendStatus(400).end(); - return; - } + app.post( + "/workspace/:slug/chat", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { slug } = request.params; + const { message, mode = "query" } = reqBody(request); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, `slug = '${slug}'`) + : await Workspace.get(`slug = '${slug}'`); - const result = await chatWithWorkspace(workspace, message, mode); - response.status(200).json({ ...result }); - } catch (e) { - response.status(500).json({ - id: uuidv4(), - type: "abort", - textResponse: null, - sources: [], - close: true, - error: e.message, - }); + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + if (multiUserMode(response) && user.role !== "admin") { + const limitMessages = + (await SystemSettings.get(`label = 'limit_user_messages'`)) + ?.value === "true"; + + if (limitMessages) { + const systemLimit = Number( + (await SystemSettings.get(`label = 'message_limit'`))?.value + ); + if (!!systemLimit) { + const currentChatCount = await WorkspaceChats.count( + `user_id = ${user.id} AND createdAt > datetime(CURRENT_TIMESTAMP, '-1 days')` + ); + if (currentChatCount >= systemLimit) { + response.status(500).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: `You have met your maximum 24 hour chat quota of ${systemLimit} chats set by the instance administrators. Try again later.`, + }); + return; + } + } + } + } + + const result = await chatWithWorkspace(workspace, message, mode, user); + response.status(200).json({ ...result }); + } catch (e) { + response.status(500).json({ + id: uuidv4(), + type: "abort", + textResponse: null, + sources: [], + close: true, + error: e.message, + }); + } } - }); + ); } module.exports = { chatEndpoints }; diff --git a/server/endpoints/invite.js b/server/endpoints/invite.js new file mode 100644 index 00000000..3f3b1452 --- /dev/null +++ b/server/endpoints/invite.js @@ -0,0 +1,63 @@ +const { Invite } = require("../models/invite"); +const { User } = require("../models/user"); +const { reqBody } = require("../utils/http"); + +function inviteEndpoints(app) { + if (!app) return; + + app.get("/invite/:code", async (request, response) => { + try { + const { code } = request.params; + const invite = await Invite.get(`code = '${code}'`); + if (!invite) { + response.status(200).json({ invite: null, error: "Invite not found." }); + return; + } + + if (invite.status !== "pending") { + response + .status(200) + .json({ invite: null, error: "Invite is no longer valid." }); + return; + } + + response + .status(200) + .json({ invite: { code, status: invite.status }, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); + + app.post("/invite/:code", async (request, response) => { + try { + const { code } = request.params; + const userParams = reqBody(request); + const invite = await Invite.get(`code = '${code}'`); + if (!invite || invite.status !== "pending") { + response + .status(200) + .json({ success: false, error: "Invite not found or is invalid." }); + return; + } + + const { user, error } = await User.create(userParams); + if (!user) { + console.error("Accepting invite:", error); + response + .status(200) + .json({ success: false, error: "Could not create user." }); + return; + } + + await Invite.markClaimed(invite.id, user); + response.status(200).json({ success: true, error: null }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + }); +} + +module.exports = { inviteEndpoints }; diff --git a/server/endpoints/system.js b/server/endpoints/system.js index a39ef3a3..e78af6ad 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -11,9 +11,17 @@ const { const { purgeDocument } = require("../utils/files/purgeDocument"); const { getVectorDbClass } = require("../utils/helpers"); const { updateENV } = require("../utils/helpers/updateENV"); -const { reqBody, makeJWT } = require("../utils/http"); +const { + reqBody, + makeJWT, + userFromSession, + multiUserMode, +} = require("../utils/http"); const { setupDataImports } = require("../utils/files/multer"); const { v4 } = require("uuid"); +const { SystemSettings } = require("../models/systemSettings"); +const { User } = require("../models/user"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); const { handleImports } = setupDataImports(); function systemEndpoints(app) { @@ -28,7 +36,7 @@ function systemEndpoints(app) { response.sendStatus(200); }); - app.get("/setup-complete", (_, response) => { + app.get("/setup-complete", async (_, response) => { try { const vectorDB = process.env.VECTOR_DB || "pinecone"; const results = { @@ -40,6 +48,7 @@ function systemEndpoints(app) { AuthToken: !!process.env.AUTH_TOKEN, JWTSecret: !!process.env.JWT_SECRET, StorageDir: process.env.STORAGE_DIR, + MultiUserMode: await SystemSettings.isMultiUserMode(), ...(vectorDB === "pinecone" ? { PineConeEnvironment: process.env.PINECONE_ENVIRONMENT, @@ -60,35 +69,101 @@ function systemEndpoints(app) { } }); - app.get("/system/check-token", (_, response) => { - response.sendStatus(200).end(); - }); + app.get( + "/system/check-token", + [validatedRequest], + async (request, response) => { + try { + if (multiUserMode(response)) { + const user = await userFromSession(request, response); + if (!user || user.suspended) { + response.sendStatus(403).end(); + return; + } - app.post("/request-token", (request, response) => { + response.sendStatus(200).end(); + return; + } + + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post("/request-token", async (request, response) => { try { - const { password } = reqBody(request); - if (password !== process.env.AUTH_TOKEN) { - response.status(402).json({ - valid: false, - token: null, - message: "Invalid password provided", + if (await SystemSettings.isMultiUserMode()) { + const { username, password } = reqBody(request); + const existingUser = await User.get(`username = '${username}'`); + + if (!existingUser) { + response.status(200).json({ + user: null, + valid: false, + token: null, + message: "[001] Invalid login credentials.", + }); + return; + } + + const bcrypt = require("bcrypt"); + if (!bcrypt.compareSync(password, existingUser.password)) { + response.status(200).json({ + user: null, + valid: false, + token: null, + message: "[002] Invalid login credentials.", + }); + return; + } + + if (existingUser.suspended) { + response.status(200).json({ + user: null, + valid: false, + token: null, + message: "[004] Account suspended by admin.", + }); + return; + } + + response.status(200).json({ + valid: true, + user: existingUser, + token: makeJWT( + { id: existingUser.id, username: existingUser.username }, + "30d" + ), + message: null, }); return; - } + } else { + const { password } = reqBody(request); + if (password !== process.env.AUTH_TOKEN) { + response.status(401).json({ + valid: false, + token: null, + message: "[003] Invalid password provided", + }); + return; + } - response.status(200).json({ - valid: true, - token: makeJWT({ p: password }, "30d"), - message: null, - }); - return; + response.status(200).json({ + valid: true, + token: makeJWT({ p: password }, "30d"), + message: null, + }); + } } catch (e) { console.log(e.message, e); response.sendStatus(500).end(); } }); - app.get("/system/system-vectors", async (_, response) => { + app.get("/system/system-vectors", [validatedRequest], async (_, response) => { try { const VectorDb = getVectorDbClass(); const vectorCount = await VectorDb.totalIndicies(); @@ -99,18 +174,22 @@ function systemEndpoints(app) { } }); - app.delete("/system/remove-document", async (request, response) => { - try { - const { name, meta } = reqBody(request); - await purgeDocument(name, meta); - response.sendStatus(200).end(); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); + app.delete( + "/system/remove-document", + [validatedRequest], + async (request, response) => { + try { + const { name, meta } = reqBody(request); + await purgeDocument(name, meta); + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } } - }); + ); - app.get("/system/local-files", async (_, response) => { + app.get("/system/local-files", [validatedRequest], async (_, response) => { try { const localFiles = await viewLocalFiles(); response.status(200).json({ localFiles }); @@ -120,57 +199,109 @@ function systemEndpoints(app) { } }); - app.get("/system/document-processing-status", async (_, response) => { - try { - const online = await checkPythonAppAlive(); - response.sendStatus(online ? 200 : 503); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); - } - }); - - app.get("/system/accepted-document-types", async (_, response) => { - try { - const types = await acceptedFileTypes(); - if (!types) { - response.sendStatus(404).end(); - return; + app.get( + "/system/document-processing-status", + [validatedRequest], + async (_, response) => { + try { + const online = await checkPythonAppAlive(); + response.sendStatus(online ? 200 : 503); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); } - - response.status(200).json({ types }); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); } - }); + ); - app.post("/system/update-env", async (request, response) => { - try { - const body = reqBody(request); - const { newValues, error } = updateENV(body); - response.status(200).json({ newValues, error }); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); + app.get( + "/system/accepted-document-types", + [validatedRequest], + async (_, response) => { + try { + const types = await acceptedFileTypes(); + if (!types) { + response.sendStatus(404).end(); + return; + } + + response.status(200).json({ types }); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } } - }); + ); - app.post("/system/update-password", async (request, response) => { - try { - const { usePassword, newPassword } = reqBody(request); - const { error } = updateENV({ - AuthToken: usePassword ? newPassword : "", - JWTSecret: usePassword ? v4() : "", - }); - response.status(200).json({ success: !error, error }); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); + app.post( + "/system/update-env", + [validatedRequest], + async (request, response) => { + try { + const body = reqBody(request); + const { newValues, error } = updateENV(body); + response.status(200).json({ newValues, error }); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } } - }); + ); - app.get("/system/data-export", async (_, response) => { + app.post( + "/system/update-password", + [validatedRequest], + async (request, response) => { + try { + const { usePassword, newPassword } = reqBody(request); + const { error } = updateENV({ + AuthToken: usePassword ? newPassword : "", + JWTSecret: usePassword ? v4() : "", + }); + response.status(200).json({ success: !error, error }); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.post( + "/system/enable-multi-user", + [validatedRequest], + async (request, response) => { + try { + const { username, password } = reqBody(request); + const multiUserModeEnabled = await SystemSettings.isMultiUserMode(); + if (multiUserModeEnabled) { + response.status(200).json({ + success: false, + error: "Multi-user mode is already enabled.", + }); + return; + } + + const { user, error } = await User.create({ + username, + password, + role: "admin", + }); + await SystemSettings.updateSettings({ + multi_user_mode: true, + users_can_delete_workspaces: false, + limit_user_messages: false, + message_limit: 25, + }); + process.env.AUTH_TOKEN = null; + process.env.JWT_SECRET = process.env.JWT_SECRET ?? v4(); // Make sure JWT_SECRET is set for JWT issuance. + response.status(200).json({ success: !!user, error }); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get("/system/data-export", [validatedRequest], async (_, response) => { try { const { filename, error } = await exportData(); response.status(200).json({ filename, error }); @@ -180,18 +311,22 @@ function systemEndpoints(app) { } }); - app.get("/system/data-exports/:filename", (request, response) => { - const filePath = - __dirname + "/../storage/exports/" + request.params.filename; - response.download(filePath, request.params.filename, (err) => { - if (err) { - response.send({ - error: err, - msg: "Problem downloading the file", - }); - } - }); - }); + app.get( + "/system/data-exports/:filename", + [validatedRequest], + (request, response) => { + const filePath = + __dirname + "/../storage/exports/" + request.params.filename; + response.download(filePath, request.params.filename, (err) => { + if (err) { + response.send({ + error: err, + msg: "Problem downloading the file", + }); + } + }); + } + ); app.post( "/system/data-import", diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 3473c6d3..f103a1c5 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -1,4 +1,4 @@ -const { reqBody } = require("../utils/http"); +const { reqBody, multiUserMode, userFromSession } = require("../utils/http"); const { Workspace } = require("../models/workspace"); const { Document } = require("../models/documents"); const { DocumentVectors } = require("../models/vectors"); @@ -13,15 +13,18 @@ const { checkPythonAppAlive, processDocument, } = require("../utils/files/documentProcessor"); +const { validatedRequest } = require("../utils/middleware/validatedRequest"); +const { SystemSettings } = require("../models/systemSettings"); const { handleUploads } = setupMulter(); function workspaceEndpoints(app) { if (!app) return; - app.post("/workspace/new", async (request, response) => { + app.post("/workspace/new", [validatedRequest], async (request, response) => { try { + const user = await userFromSession(request, response); const { name = null } = reqBody(request); - const { workspace, message } = await Workspace.new(name); + const { workspace, message } = await Workspace.new(name, user?.id); response.status(200).json({ workspace, message }); } catch (e) { console.log(e.message, e); @@ -29,27 +32,34 @@ function workspaceEndpoints(app) { } }); - app.post("/workspace/:slug/update", async (request, response) => { - try { - const { slug = null } = request.params; - const data = reqBody(request); - const currWorkspace = await Workspace.get(`slug = '${slug}'`); + app.post( + "/workspace/:slug/update", + [validatedRequest], + async (request, response) => { + try { + const user = await userFromSession(request, response); + const { slug = null } = request.params; + const data = reqBody(request); + const currWorkspace = multiUserMode(response) + ? await Workspace.getWithUser(user, `slug = '${slug}'`) + : await Workspace.get(`slug = '${slug}'`); - if (!currWorkspace) { - response.sendStatus(400).end(); - return; + if (!currWorkspace) { + response.sendStatus(400).end(); + return; + } + + const { workspace, message } = await Workspace.update( + currWorkspace.id, + data + ); + response.status(200).json({ workspace, message }); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); } - - const { workspace, message } = await Workspace.update( - currWorkspace.id, - data - ); - response.status(200).json({ workspace, message }); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); } - }); + ); app.post( "/workspace/:slug/upload", @@ -81,57 +91,85 @@ function workspaceEndpoints(app) { } ); - app.post("/workspace/:slug/update-embeddings", async (request, response) => { - try { - const { slug = null } = request.params; - const { adds = [], deletes = [] } = reqBody(request); - const currWorkspace = await Workspace.get(`slug = '${slug}'`); - - if (!currWorkspace) { - response.sendStatus(400).end(); - return; - } - - await Document.removeDocuments(currWorkspace, deletes); - await Document.addDocuments(currWorkspace, adds); - const updatedWorkspace = await Workspace.get(`slug = '${slug}'`); - response.status(200).json({ workspace: updatedWorkspace }); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); - } - }); - - app.delete("/workspace/:slug", async (request, response) => { - try { - const VectorDb = getVectorDbClass(); - const { slug = "" } = request.params; - const workspace = await Workspace.get(`slug = '${slug}'`); - - if (!workspace) { - response.sendStatus(400).end(); - return; - } - - await Workspace.delete(`slug = '${slug.toLowerCase()}'`); - await DocumentVectors.deleteForWorkspace(workspace.id); - await Document.delete(`workspaceId = ${Number(workspace.id)}`); - await WorkspaceChats.delete(`workspaceId = ${Number(workspace.id)}`); + app.post( + "/workspace/:slug/update-embeddings", + [validatedRequest], + async (request, response) => { try { - await VectorDb["delete-namespace"]({ namespace: slug }); - } catch (e) { - console.error(e.message); - } - response.sendStatus(200).end(); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); - } - }); + const user = await userFromSession(request, response); + const { slug = null } = request.params; + const { adds = [], deletes = [] } = reqBody(request); + const currWorkspace = multiUserMode(response) + ? await Workspace.getWithUser(user, `slug = '${slug}'`) + : await Workspace.get(`slug = '${slug}'`); - app.get("/workspaces", async (_, response) => { + if (!currWorkspace) { + response.sendStatus(400).end(); + return; + } + + await Document.removeDocuments(currWorkspace, deletes); + await Document.addDocuments(currWorkspace, adds); + const updatedWorkspace = await Workspace.get(`slug = '${slug}'`); + response.status(200).json({ workspace: updatedWorkspace }); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug", + [validatedRequest], + async (request, response) => { + try { + const { slug = "" } = request.params; + const user = await userFromSession(request, response); + const VectorDb = getVectorDbClass(); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, `slug = '${slug}'`) + : await Workspace.get(`slug = '${slug}'`); + + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + if (multiUserMode(response) && user.role !== "admin") { + const canDelete = + (await SystemSettings.get(`label = 'users_can_delete_workspaces'`)) + ?.value === "true"; + if (!canDelete) { + response.sendStatus(500).end(); + return; + } + } + + await Workspace.delete(`slug = '${slug.toLowerCase()}'`); + await DocumentVectors.deleteForWorkspace(workspace.id); + await Document.delete(`workspaceId = ${Number(workspace.id)}`); + await WorkspaceChats.delete(`workspaceId = ${Number(workspace.id)}`); + try { + await VectorDb["delete-namespace"]({ namespace: slug }); + } catch (e) { + console.error(e.message); + } + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + + app.get("/workspaces", [validatedRequest], async (request, response) => { try { - const workspaces = await Workspace.where(); + const user = await userFromSession(request, response); + const workspaces = multiUserMode(response) + ? await Workspace.whereWithUser(user) + : await Workspace.where(); + response.status(200).json({ workspaces }); } catch (e) { console.log(e.message, e); @@ -139,10 +177,14 @@ function workspaceEndpoints(app) { } }); - app.get("/workspace/:slug", async (request, response) => { + app.get("/workspace/:slug", [validatedRequest], async (request, response) => { try { const { slug } = request.params; - const workspace = await Workspace.get(`slug = '${slug}'`); + const user = await userFromSession(request, response); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, `slug = '${slug}'`) + : await Workspace.get(`slug = '${slug}'`); + response.status(200).json({ workspace }); } catch (e) { console.log(e.message, e); @@ -150,22 +192,33 @@ function workspaceEndpoints(app) { } }); - app.get("/workspace/:slug/chats", async (request, response) => { - try { - const { slug } = request.params; - const workspace = await Workspace.get(`slug = '${slug}'`); - if (!workspace) { - response.sendStatus(400).end(); - return; - } + app.get( + "/workspace/:slug/chats", + [validatedRequest], + async (request, response) => { + try { + const { slug } = request.params; + const user = await userFromSession(request, response); + const workspace = multiUserMode(response) + ? await Workspace.getWithUser(user, `slug = '${slug}'`) + : await Workspace.get(`slug = '${slug}'`); - const history = await WorkspaceChats.forWorkspace(workspace.id); - response.status(200).json({ history: convertToChatHistory(history) }); - } catch (e) { - console.log(e.message, e); - response.sendStatus(500).end(); + if (!workspace) { + response.sendStatus(400).end(); + return; + } + + const history = multiUserMode(response) + ? await WorkspaceChats.forWorkspaceByUser(workspace.id, user.id) + : await WorkspaceChats.forWorkspace(workspace.id); + + response.status(200).json({ history: convertToChatHistory(history) }); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } } - }); + ); } module.exports = { workspaceEndpoints }; diff --git a/server/index.js b/server/index.js index 83ff994d..0164a03e 100644 --- a/server/index.js +++ b/server/index.js @@ -7,13 +7,14 @@ const bodyParser = require("body-parser"); const serveIndex = require("serve-index"); const cors = require("cors"); const path = require("path"); -const { validatedRequest } = require("./utils/middleware/validatedRequest"); const { reqBody } = require("./utils/http"); const { systemEndpoints } = require("./endpoints/system"); const { workspaceEndpoints } = require("./endpoints/workspaces"); const { chatEndpoints } = require("./endpoints/chat"); const { getVectorDbClass } = require("./utils/helpers"); const { validateTablePragmas } = require("./utils/database"); +const { adminEndpoints } = require("./endpoints/admin"); +const { inviteEndpoints } = require("./endpoints/invite"); const app = express(); const apiRouter = express.Router(); @@ -26,12 +27,12 @@ app.use( }) ); -apiRouter.use("/system/*", validatedRequest); +app.use("/api", apiRouter); systemEndpoints(apiRouter); - -apiRouter.use("/workspace/*", validatedRequest); workspaceEndpoints(apiRouter); chatEndpoints(apiRouter); +adminEndpoints(apiRouter); +inviteEndpoints(apiRouter); apiRouter.post("/v/:command", async (request, response) => { try { @@ -61,8 +62,6 @@ apiRouter.post("/v/:command", async (request, response) => { } }); -app.use("/api", apiRouter); - if (process.env.NODE_ENV !== "development") { app.use( express.static(path.resolve(__dirname, "public"), { extensions: ["js"] }) diff --git a/server/models/documents.js b/server/models/documents.js index 777bd717..0de83bcd 100644 --- a/server/models/documents.js +++ b/server/models/documents.js @@ -35,7 +35,7 @@ const Document = { }); await db.exec( - `CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` + `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` ); if (tracing) db.on("trace", (sql) => console.log(sql)); diff --git a/server/models/invite.js b/server/models/invite.js new file mode 100644 index 00000000..0a945c4f --- /dev/null +++ b/server/models/invite.js @@ -0,0 +1,191 @@ +const Invite = { + tablename: "invites", + writable: [], + colsInit: ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + status TEXT NOT NULL DEFAULT "pending", + claimedBy INTEGER DEFAULT NULL, + createdAt TEXT DEFAULT CURRENT_TIMESTAMP, + createdBy INTEGER NOT NULL, + lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP + `, + migrateTable: async function () { + const { checkForMigrations } = require("../utils/database"); + console.log(`\x1b[34m[MIGRATING]\x1b[0m Checking for Invites migrations`); + const db = await this.db(false); + await checkForMigrations(this, db); + }, + migrations: function () { + return []; + }, + makeCode: () => { + const uuidAPIKey = require("uuid-apikey"); + return uuidAPIKey.create().apiKey; + }, + db: async function (tracing = true) { + const sqlite3 = require("sqlite3").verbose(); + const { open } = require("sqlite"); + + const db = await open({ + filename: `${ + !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "storage/" + }anythingllm.db`, + driver: sqlite3.Database, + }); + + await db.exec( + `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` + ); + + if (tracing) db.on("trace", (sql) => console.log(sql)); + return db; + }, + create: async function (createdByUserId = 0) { + const db = await this.db(); + const { id, success, message } = await db + .run(`INSERT INTO ${this.tablename} (code, createdBy) VALUES(?, ?)`, [ + this.makeCode(), + createdByUserId, + ]) + .then((res) => { + return { id: res.lastID, success: true, message: null }; + }) + .catch((error) => { + return { id: null, success: false, message: error.message }; + }); + + if (!success) { + db.close(); + console.error("FAILED TO CREATE USER.", message); + return { invite: null, error: message }; + } + + const invite = await db.get( + `SELECT * FROM ${this.tablename} WHERE id = ${id} ` + ); + db.close(); + + return { invite, error: null }; + }, + deactivate: async function (inviteId = null) { + const invite = await this.get(`id = ${inviteId}`); + if (!invite) return { success: false, error: "Invite does not exist." }; + if (invite.status !== "pending") + return { success: false, error: "Invite is not in pending status." }; + + const db = await this.db(); + const { success, message } = await db + .run(`UPDATE ${this.tablename} SET status=? WHERE id=?`, [ + "disabled", + inviteId, + ]) + .then(() => { + return { success: true, message: null }; + }) + .catch((error) => { + return { success: false, message: error.message }; + }); + + db.close(); + if (!success) { + console.error(message); + return { success: false, error: message }; + } + + return { success: true, error: null }; + }, + markClaimed: async function (inviteId = null, user) { + const invite = await this.get(`id = ${inviteId}`); + if (!invite) return { success: false, error: "Invite does not exist." }; + if (invite.status !== "pending") + return { success: false, error: "Invite is not in pending status." }; + + const db = await this.db(); + const { success, message } = await db + .run(`UPDATE ${this.tablename} SET status=?,claimedBy=? WHERE id=?`, [ + "claimed", + user.id, + inviteId, + ]) + .then(() => { + return { success: true, message: null }; + }) + .catch((error) => { + return { success: false, message: error.message }; + }); + + db.close(); + if (!success) { + console.error(message); + return { success: false, error: message }; + } + + return { success: true, error: null }; + }, + get: async function (clause = "") { + const db = await this.db(); + const result = await db + .get( + `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : clause}` + ) + .then((res) => res || null); + if (!result) return null; + db.close(); + return { ...result }; + }, + count: async function (clause = null) { + const db = await this.db(); + const { count } = await db.get( + `SELECT COUNT(*) as count FROM ${this.tablename} ${ + clause ? `WHERE ${clause}` : "" + } ` + ); + db.close(); + + return count; + }, + delete: async function (clause = "") { + const db = await this.db(); + await db.get(`DELETE FROM ${this.tablename} WHERE ${clause}`); + db.close(); + + return true; + }, + where: async function (clause = "", limit = null) { + const db = await this.db(); + const results = await db.all( + `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${ + !!limit ? `LIMIT ${limit}` : "" + }` + ); + db.close(); + + return results; + }, + whereWithUsers: async function (clause = "", limit = null) { + const { User } = require("./user"); + const results = await this.where(clause, limit); + for (const invite of results) { + console.log(invite); + if (!!invite.claimedBy) { + const acceptedUser = await User.get(`id = ${invite.claimedBy}`); + invite.claimedBy = { + id: acceptedUser?.id, + username: acceptedUser?.username, + }; + } + + if (!!invite.createdBy) { + const createdUser = await User.get(`id = ${invite.createdBy}`); + invite.createdBy = { + id: createdUser?.id, + username: createdUser?.username, + }; + } + } + return results; + }, +}; + +module.exports.Invite = Invite; diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js new file mode 100644 index 00000000..1df81204 --- /dev/null +++ b/server/models/systemSettings.js @@ -0,0 +1,122 @@ +const SystemSettings = { + supportedFields: [ + "multi_user_mode", + "users_can_delete_workspaces", + "limit_user_messages", + "message_limit", + ], + privateField: [], + tablename: "system_settings", + colsInit: ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT UNIQUE NOT NULL, + value TEXT, + createdAt TEXT DEFAULT CURRENT_TIMESTAMP, + lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP + `, + migrateTable: async function () { + const { checkForMigrations } = require("../utils/database"); + console.log( + `\x1b[34m[MIGRATING]\x1b[0m Checking for System Setting migrations` + ); + const db = await this.db(false); + await checkForMigrations(this, db); + }, + migrations: function () { + return []; + }, + db: async function (tracing = true) { + const sqlite3 = require("sqlite3").verbose(); + const { open } = require("sqlite"); + + const db = await open({ + filename: `${ + !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "storage/" + }anythingllm.db`, + driver: sqlite3.Database, + }); + + await db.exec( + `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` + ); + + if (tracing) db.on("trace", (sql) => console.log(sql)); + return db; + }, + get: async function (clause = "") { + const db = await this.db(); + const result = await db + .get(`SELECT * FROM ${this.tablename} WHERE ${clause}`) + .then((res) => res || null); + if (!result) return null; + db.close(); + + return result; + }, + where: async function (clause = null, limit = null) { + const db = await this.db(); + const results = await db.all( + `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${ + !!limit ? `LIMIT ${limit}` : "" + }` + ); + db.close(); + + return results; + }, + updateSettings: async function (updates = {}) { + const validConfigKeys = Object.keys(updates).filter((key) => + this.supportedFields.includes(key) + ); + for (const key of validConfigKeys) { + const existingRecord = await this.get(`label = '${key}'`); + if (!existingRecord) { + const db = await this.db(); + const value = updates[key] === null ? null : String(updates[key]); + const { success, message } = await db + .run(`INSERT INTO ${this.tablename} (label, value) VALUES (?, ?)`, [ + key, + value, + ]) + .then((res) => { + return { id: res.lastID, success: true, message: null }; + }) + .catch((error) => { + return { id: null, success: false, message: error.message }; + }); + db.close(); + if (!success) { + console.error("FAILED TO ADD SYSTEM CONFIG OPTION", message); + return { success: false, error: message }; + } + } else { + const db = await this.db(); + const value = updates[key] === null ? null : String(updates[key]); + const { success, message } = await db + .run(`UPDATE ${this.tablename} SET label=?,value=? WHERE id = ?`, [ + key, + value, + existingRecord.id, + ]) + .then(() => { + return { success: true, message: null }; + }) + .catch((error) => { + return { success: false, message: error.message }; + }); + + db.close(); + if (!success) { + console.error("FAILED TO UPDATE SYSTEM CONFIG OPTION", message); + return { success: false, error: message }; + } + } + } + return { success: true, error: null }; + }, + isMultiUserMode: async function () { + return (await this.get(`label = 'multi_user_mode'`))?.value === "true"; + }, +}; + +module.exports.SystemSettings = SystemSettings; diff --git a/server/models/user.js b/server/models/user.js new file mode 100644 index 00000000..9bfee350 --- /dev/null +++ b/server/models/user.js @@ -0,0 +1,170 @@ +const User = { + tablename: "users", + writable: [], + colsInit: ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE, + password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT "default", + suspended INTEGER NOT NULL DEFAULT 0, + createdAt TEXT DEFAULT CURRENT_TIMESTAMP, + lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP + `, + migrateTable: async function () { + const { checkForMigrations } = require("../utils/database"); + console.log(`\x1b[34m[MIGRATING]\x1b[0m Checking for User migrations`); + const db = await this.db(false); + await checkForMigrations(this, db); + }, + migrations: function () { + return []; + }, + db: async function (tracing = true) { + const sqlite3 = require("sqlite3").verbose(); + const { open } = require("sqlite"); + + const db = await open({ + filename: `${ + !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "storage/" + }anythingllm.db`, + driver: sqlite3.Database, + }); + + await db.exec( + `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` + ); + + if (tracing) db.on("trace", (sql) => console.log(sql)); + return db; + }, + create: async function ({ username, password, role = null }) { + const bcrypt = require("bcrypt"); + const db = await this.db(); + const { id, success, message } = await db + .run( + `INSERT INTO ${this.tablename} (username, password, role) VALUES(?, ?, ?)`, + [username, bcrypt.hashSync(password, 10), role ?? "default"] + ) + .then((res) => { + return { id: res.lastID, success: true, message: null }; + }) + .catch((error) => { + return { id: null, success: false, message: error.message }; + }); + + if (!success) { + db.close(); + console.error("FAILED TO CREATE USER.", message); + return { user: null, error: message }; + } + + const user = await db.get( + `SELECT * FROM ${this.tablename} WHERE id = ${id} ` + ); + db.close(); + + return { user, error: null }; + }, + update: async function (userId, updates = {}) { + const user = await this.get(`id = ${userId}`); + if (!user) return { success: false, error: "User does not exist." }; + const { username, password, role, suspended = 0 } = updates; + const toUpdate = { suspended }; + + if (user.username !== username && username?.length > 0) { + const usedUsername = !!(await this.get(`username = '${username}'`)); + if (usedUsername) + return { success: false, error: `${username} is already in use.` }; + toUpdate.username = username; + } + + if (!!password) { + const bcrypt = require("bcrypt"); + toUpdate.password = bcrypt.hashSync(password, 10); + } + + if (user.role !== role && ["admin", "default"].includes(role)) { + // If was existing admin and that has been changed + // make sure at least one admin exists + if (user.role === "admin") { + const validAdminCount = (await this.count(`role = 'admin'`)) > 1; + if (!validAdminCount) + return { + success: false, + error: `There would be no admins if this action was completed. There must be at least one admin.`, + }; + } + + toUpdate.role = role; + } + + if (Object.keys(toUpdate).length !== 0) { + const values = Object.values(toUpdate); + const template = `UPDATE ${this.tablename} SET ${Object.keys( + toUpdate + ).map((key) => { + return `${key}=?`; + })} WHERE id = ?`; + + const db = await this.db(); + const { success, message } = await db + .run(template, [...values, userId]) + .then(() => { + return { success: true, message: null }; + }) + .catch((error) => { + return { success: false, message: error.message }; + }); + + db.close(); + if (!success) { + console.error(message); + return { success: false, error: message }; + } + } + + return { success: true, error: null }; + }, + get: async function (clause = "") { + const db = await this.db(); + const result = await db + .get( + `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : clause}` + ) + .then((res) => res || null); + if (!result) return null; + db.close(); + return { ...result }; + }, + count: async function (clause = null) { + const db = await this.db(); + const { count } = await db.get( + `SELECT COUNT(*) as count FROM ${this.tablename} ${ + clause ? `WHERE ${clause}` : "" + } ` + ); + db.close(); + + return count; + }, + delete: async function (clause = "") { + const db = await this.db(); + await db.get(`DELETE FROM ${this.tablename} WHERE ${clause}`); + db.close(); + + return true; + }, + where: async function (clause = "", limit = null) { + const db = await this.db(); + const results = await db.all( + `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${ + !!limit ? `LIMIT ${limit}` : "" + }` + ); + db.close(); + + return results; + }, +}; + +module.exports = { User }; diff --git a/server/models/vectors.js b/server/models/vectors.js index 9e1a8dd4..e568097b 100644 --- a/server/models/vectors.js +++ b/server/models/vectors.js @@ -34,7 +34,7 @@ const DocumentVectors = { }); await db.exec( - `CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` + `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` ); if (tracing) db.on("trace", (sql) => console.log(sql)); diff --git a/server/models/workspace.js b/server/models/workspace.js index fed92434..5049a2ba 100644 --- a/server/models/workspace.js +++ b/server/models/workspace.js @@ -1,6 +1,7 @@ const slugify = require("slugify"); const { Document } = require("./documents"); const { checkForMigrations } = require("../utils/database"); +const { WorkspaceUser } = require("./workspaceUsers"); const Workspace = { tablename: "workspaces", @@ -70,13 +71,13 @@ const Workspace = { }); await db.exec( - `CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` + `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` ); if (tracing) db.on("trace", (sql) => console.log(sql)); return db; }, - new: async function (name = null) { + new: async function (name = null, creatorId = null) { if (!name) return { result: null, message: "name cannot be null" }; var slug = slugify(name, { lower: true }); @@ -109,6 +110,10 @@ const Workspace = { ); db.close(); + // If created with a user then we need to create the relationship as well. + // If creating with an admin User it wont change anything because admins can + // view all workspaces anyway. + if (!!creatorId) await WorkspaceUser.create(creatorId, workspace.id); return { workspace, message: null }; }, update: async function (id = null, data = {}) { @@ -142,6 +147,25 @@ const Workspace = { const updatedWorkspace = await this.get(`id = ${id}`); return { workspace: updatedWorkspace, message: null }; }, + getWithUser: async function (user = null, clause = "") { + if (user.role === "admin") return this.get(clause); + + const db = await this.db(); + const result = await db + .get( + `SELECT * FROM ${this.tablename} as workspace + LEFT JOIN workspace_users as ws_users + ON ws_users.workspace_id = workspace.id + WHERE ws_users.user_id = ${user?.id} AND ${clause}` + ) + .then((res) => res || null); + if (!result) return null; + db.close(); + + const workspace = { ...result, id: result.workspace_id }; + const documents = await Document.forWorkspace(workspace.id); + return { ...workspace, documents }; + }, get: async function (clause = "") { const db = await this.db(); const result = await db @@ -160,17 +184,55 @@ const Workspace = { return true; }, - where: async function (clause = "", limit = null) { + where: async function (clause = "", limit = null, orderBy = null) { const db = await this.db(); const results = await db.all( `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${ !!limit ? `LIMIT ${limit}` : "" - }` + } ${!!orderBy ? orderBy : ""}` ); db.close(); return results; }, + whereWithUser: async function ( + user, + clause = null, + limit = null, + orderBy = null + ) { + if (user.role === "admin") return await this.where(clause, limit); + const db = await this.db(); + const results = await db.all( + `SELECT * FROM ${this.tablename} as workspace + LEFT JOIN workspace_users as ws_users + ON ws_users.workspace_id = workspace.id + WHERE ws_users.user_id = ${user.id} ${clause ? `AND ${clause}` : ""} ${ + !!limit ? `LIMIT ${limit}` : "" + } ${!!orderBy ? orderBy : ""}` + ); + db.close(); + const workspaces = results.map((ws) => { + return { ...ws, id: ws.workspace_id }; + }); + + return workspaces; + }, + whereWithUsers: async function (clause = "", limit = null, orderBy = null) { + const workspaces = await this.where(clause, limit, orderBy); + for (const workspace of workspaces) { + const userIds = ( + await WorkspaceUser.where(`workspace_id = ${workspace.id}`) + ).map((rel) => rel.user_id); + workspace.userIds = userIds; + } + return workspaces; + }, + updateUsers: async function (workspaceId, userIds = []) { + await WorkspaceUser.delete(`workspace_id = ${workspaceId}`); + await WorkspaceUser.createManyUsers(userIds, workspaceId); + return { success: true, error: null }; + }, }; module.exports = { Workspace }; diff --git a/server/models/workspaceChats.js b/server/models/workspaceChats.js index 7a2aafb8..027448bf 100644 --- a/server/models/workspaceChats.js +++ b/server/models/workspaceChats.js @@ -8,8 +8,11 @@ const WorkspaceChats = { prompt TEXT NOT NULL, response TEXT NOT NULL, include BOOL DEFAULT true, + user_id INTEGER DEFAULT NULL, createdAt TEXT DEFAULT CURRENT_TIMESTAMP, - lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP + lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE `, migrateTable: async function () { console.log( @@ -19,7 +22,13 @@ const WorkspaceChats = { await checkForMigrations(this, db); }, migrations: function () { - return []; + return [ + { + colName: "user_id", + execCmd: `ALTER TABLE ${this.tablename} ADD COLUMN user_id INTEGER DEFAULT NULL`, + doif: false, + }, + ]; }, db: async function (tracing = true) { const sqlite3 = require("sqlite3").verbose(); @@ -33,18 +42,18 @@ const WorkspaceChats = { }); await db.exec( - `CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` + `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` ); if (tracing) db.on("trace", (sql) => console.log(sql)); return db; }, - new: async function ({ workspaceId, prompt, response = {} }) { + new: async function ({ workspaceId, prompt, response = {}, user = null }) { const db = await this.db(); const { id, success, message } = await db .run( - `INSERT INTO ${this.tablename} (workspaceId, prompt, response) VALUES (?, ?, ?)`, - [workspaceId, prompt, JSON.stringify(response)] + `INSERT INTO ${this.tablename} (workspaceId, prompt, response, user_id) VALUES (?, ?, ?, ?)`, + [workspaceId, prompt, JSON.stringify(response), user?.id || null] ) .then((res) => { return { id: res.lastID, success: true, message: null }; @@ -64,6 +73,18 @@ const WorkspaceChats = { return { chat, message: null }; }, + forWorkspaceByUser: async function ( + workspaceId = null, + userId = null, + limit = null + ) { + if (!workspaceId || !userId) return []; + return await this.where( + `workspaceId = ${workspaceId} AND include = true AND user_id = ${userId}`, + limit, + "ORDER BY id ASC" + ); + }, forWorkspace: async function (workspaceId = null, limit = null) { if (!workspaceId) return []; return await this.where( @@ -72,21 +93,27 @@ const WorkspaceChats = { "ORDER BY id ASC" ); }, - markHistoryInvalid: async function (workspaceId = null) { + markHistoryInvalid: async function (workspaceId = null, user = null) { if (!workspaceId) return; const db = await this.db(); await db.run( - `UPDATE ${this.tablename} SET include = false WHERE workspaceId = ?`, + `UPDATE ${this.tablename} SET include = false WHERE workspaceId = ? ${ + user ? `AND user_id = ${user.id}` : "" + }`, [workspaceId] ); db.close(); return; }, - get: async function (clause = "") { + get: async function (clause = "", limit = null, order = null) { const db = await this.db(); const result = await db - .get(`SELECT * FROM ${this.tablename} WHERE ${clause}`) + .get( + `SELECT * FROM ${this.tablename} WHERE ${clause} ${ + !!order ? order : "" + } ${!!limit ? `LIMIT ${limit}` : ""}` + ) .then((res) => res || null); db.close(); @@ -105,12 +132,40 @@ const WorkspaceChats = { const results = await db.all( `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${ !!order ? order : "" - } ${!!limit ? `LIMIT ${limit}` : ""} ` + } ${!!limit ? `LIMIT ${limit}` : ""}` ); db.close(); return results; }, + count: async function (clause = null) { + const db = await this.db(); + const { count } = await db.get( + `SELECT COUNT(*) as count FROM ${this.tablename} ${ + clause ? `WHERE ${clause}` : "" + } ` + ); + db.close(); + + return count; + }, + whereWithData: async function (clause = "", limit = null, order = null) { + const { Workspace } = require("./workspace"); + const { User } = require("./user"); + const results = await this.where(clause, limit, order); + for (const res of results) { + const workspace = await Workspace.get(`id = ${res.workspaceId}`); + res.workspace = workspace + ? { name: workspace.name, slug: workspace.slug } + : { name: "deleted workspace", slug: null }; + + const user = await User.get(`id = ${res.user_id}`); + res.user = user + ? { username: user.username } + : { username: "deleted user" }; + } + return results; + }, }; module.exports = { WorkspaceChats }; diff --git a/server/models/workspaceUsers.js b/server/models/workspaceUsers.js new file mode 100644 index 00000000..8dacbac1 --- /dev/null +++ b/server/models/workspaceUsers.js @@ -0,0 +1,132 @@ +const WorkspaceUser = { + tablename: "workspace_users", + colsInit: ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + workspace_id INTEGER NOT NULL, + createdAt TEXT DEFAULT CURRENT_TIMESTAMP, + lastUpdatedAt TEXT DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE + `, + migrateTable: async function () { + const { checkForMigrations } = require("../utils/database"); + console.log( + `\x1b[34m[MIGRATING]\x1b[0m Checking for Workspace User migrations` + ); + const db = await this.db(false); + await checkForMigrations(this, db); + }, + migrations: function () { + return []; + }, + db: async function (tracing = true) { + const sqlite3 = require("sqlite3").verbose(); + const { open } = require("sqlite"); + + const db = await open({ + filename: `${ + !!process.env.STORAGE_DIR ? `${process.env.STORAGE_DIR}/` : "storage/" + }anythingllm.db`, + driver: sqlite3.Database, + }); + + await db.exec( + `PRAGMA foreign_keys = ON;CREATE TABLE IF NOT EXISTS ${this.tablename} (${this.colsInit})` + ); + + if (tracing) db.on("trace", (sql) => console.log(sql)); + return db; + }, + createMany: async function (userId, workspaceIds = []) { + if (workspaceIds.length === 0) return; + const db = await this.db(); + const stmt = await db.prepare( + `INSERT INTO ${this.tablename} (user_id, workspace_id) VALUES (?,?)` + ); + + for (const workspaceId of workspaceIds) { + stmt.run([userId, workspaceId]); + } + + stmt.finalize(); + db.close(); + return; + }, + createManyUsers: async function (userIds = [], workspaceId) { + if (userIds.length === 0) return; + const db = await this.db(); + const stmt = await db.prepare( + `INSERT INTO ${this.tablename} (user_id, workspace_id) VALUES (?,?)` + ); + + for (const userId of userIds) { + stmt.run([userId, workspaceId]); + } + + stmt.finalize(); + db.close(); + return; + }, + create: async function (userId = 0, workspaceId = 0) { + const db = await this.db(); + const { success, message } = await db + .run( + `INSERT INTO ${this.tablename} (user_id, workspace_id) VALUES (?, ?)`, + [userId, workspaceId] + ) + .then((res) => { + return { id: res.lastID, success: true, message: null }; + }) + .catch((error) => { + return { id: null, success: false, message: error.message }; + }); + + if (!success) { + db.close(); + console.error("FAILED TO CREATE WORKSPACE_USER RELATIONSHIP.", message); + return false; + } + return true; + }, + get: async function (clause = "") { + const db = await this.db(); + const result = await db + .get(`SELECT * FROM ${this.tablename} WHERE ${clause}`) + .then((res) => res || null); + if (!result) return null; + db.close(); + + return result; + }, + where: async function (clause = null, limit = null) { + const db = await this.db(); + const results = await db.all( + `SELECT * FROM ${this.tablename} ${clause ? `WHERE ${clause}` : ""} ${ + !!limit ? `LIMIT ${limit}` : "" + }` + ); + db.close(); + + return results; + }, + count: async function (clause = null) { + const db = await this.db(); + const { count } = await db.get( + `SELECT COUNT(*) as count FROM ${this.tablename} ${ + clause ? `WHERE ${clause}` : "" + }` + ); + db.close(); + + return count; + }, + delete: async function (clause = null) { + const db = await this.db(); + await db.get(`DELETE FROM ${this.tablename} WHERE ${clause}`); + return; + }, +}; + +module.exports.WorkspaceUser = WorkspaceUser; diff --git a/server/package.json b/server/package.json index c9cc4d20..24b84210 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "@googleapis/youtube": "^9.0.0", "@pinecone-database/pinecone": "^0.1.6", "archiver": "^5.3.1", + "bcrypt": "^5.1.0", "body-parser": "^1.20.2", "chromadb": "^1.5.2", "cors": "^2.8.5", @@ -35,6 +36,7 @@ "sqlite": "^4.2.1", "sqlite3": "^5.1.6", "uuid": "^9.0.0", + "uuid-apikey": "^1.5.3", "vectordb": "0.1.12" }, "devDependencies": { diff --git a/server/utils/chats/commands/reset.js b/server/utils/chats/commands/reset.js index 59f9448e..8851efdf 100644 --- a/server/utils/chats/commands/reset.js +++ b/server/utils/chats/commands/reset.js @@ -1,7 +1,7 @@ const { WorkspaceChats } = require("../../../models/workspaceChats"); -async function resetMemory(workspace, _message, msgUUID) { - await WorkspaceChats.markHistoryInvalid(workspace.id); +async function resetMemory(workspace, _message, msgUUID, user = null) { + await WorkspaceChats.markHistoryInvalid(workspace.id, user); return { uuid: msgUUID, type: "textResponse", diff --git a/server/utils/chats/index.js b/server/utils/chats/index.js index 800003e2..5e099dad 100644 --- a/server/utils/chats/index.js +++ b/server/utils/chats/index.js @@ -59,14 +59,19 @@ function grepCommand(message) { return null; } -async function chatWithWorkspace(workspace, message, chatMode = "chat") { +async function chatWithWorkspace( + workspace, + message, + chatMode = "chat", + user = null +) { const uuid = uuidv4(); const openai = new OpenAi(); const VectorDb = getVectorDbClass(); const command = grepCommand(message); if (!!command && Object.keys(VALID_COMMANDS).includes(command)) { - return await VALID_COMMANDS[command](workspace, message, uuid); + return await VALID_COMMANDS[command](workspace, message, uuid, user); } const { safe, reasons = [] } = await openai.isSafe(message); @@ -84,7 +89,8 @@ async function chatWithWorkspace(workspace, message, chatMode = "chat") { } const hasVectorizedSpace = await VectorDb.hasNamespace(workspace.slug); - if (!hasVectorizedSpace) { + const embeddingsCount = await VectorDb.namespaceCount(workspace.slug); + if (!hasVectorizedSpace || embeddingsCount === 0) { const rawHistory = await WorkspaceChats.forWorkspace(workspace.id); const chatHistory = convertToPromptHistory(rawHistory); const response = await openai.sendChat(chatHistory, message, workspace); @@ -94,6 +100,7 @@ async function chatWithWorkspace(workspace, message, chatMode = "chat") { workspaceId: workspace.id, prompt: message, response: data, + user, }); return { id: uuid, @@ -137,6 +144,7 @@ async function chatWithWorkspace(workspace, message, chatMode = "chat") { workspaceId: workspace.id, prompt: message, response: data, + user, }); return { id: uuid, diff --git a/server/utils/database/index.js b/server/utils/database/index.js index 0b1f42bd..e3b658a5 100644 --- a/server/utils/database/index.js +++ b/server/utils/database/index.js @@ -50,15 +50,23 @@ async function validateTablePragmas(force = false) { ); return; } - + const { SystemSettings } = require("../../models/systemSettings"); + const { User } = require("../../models/user"); const { Workspace } = require("../../models/workspace"); + const { WorkspaceUser } = require("../../models/workspaceUsers"); const { Document } = require("../../models/documents"); const { DocumentVectors } = require("../../models/vectors"); const { WorkspaceChats } = require("../../models/workspaceChats"); + const { Invite } = require("../../models/invite"); + + await SystemSettings.migrateTable(); + await User.migrateTable(); await Workspace.migrateTable(); + await WorkspaceUser.migrateTable(); await Document.migrateTable(); await DocumentVectors.migrateTable(); await WorkspaceChats.migrateTable(); + await Invite.migrateTable(); } catch (e) { console.error(`validateTablePragmas: Migrations failed`, e); } diff --git a/server/utils/http/index.js b/server/utils/http/index.js index 9fd643b7..3543a36b 100644 --- a/server/utils/http/index.js +++ b/server/utils/http/index.js @@ -2,6 +2,7 @@ process.env.NODE_ENV === "development" ? require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }) : require("dotenv").config(); const JWT = require("jsonwebtoken"); +const { User } = require("../../models/user"); function reqBody(request) { return typeof request.body === "string" @@ -19,16 +20,43 @@ function makeJWT(info = {}, expiry = "30d") { return JWT.sign(info, process.env.JWT_SECRET, { expiresIn: expiry }); } +async function userFromSession(request, response = null) { + if (!!response && !!response.locals?.user) { + return response.locals.user; + } + + const auth = request.header("Authorization"); + const token = auth ? auth.split(" ")[1] : null; + + if (!token) { + return null; + } + + const valid = decodeJWT(token); + if (!valid || !valid.id) { + return null; + } + + const user = await User.get(`id = ${valid.id}`); + return user; +} + function decodeJWT(jwtToken) { try { return JWT.verify(jwtToken, process.env.JWT_SECRET); } catch {} - return { p: null }; + return { p: null, id: null, username: null }; +} + +function multiUserMode(response) { + return response?.locals?.multiUserMode; } module.exports = { reqBody, + multiUserMode, queryParams, makeJWT, decodeJWT, + userFromSession, }; diff --git a/server/utils/middleware/validatedRequest.js b/server/utils/middleware/validatedRequest.js index 4e7c519a..1ff13f3d 100644 --- a/server/utils/middleware/validatedRequest.js +++ b/server/utils/middleware/validatedRequest.js @@ -1,6 +1,13 @@ +const { SystemSettings } = require("../../models/systemSettings"); +const { User } = require("../../models/user"); const { decodeJWT } = require("../http"); -function validatedRequest(request, response, next) { +async function validatedRequest(request, response, next) { + const multiUserMode = await SystemSettings.isMultiUserMode(); + response.locals.multiUserMode = multiUserMode; + if (multiUserMode) + return await validateMultiUserRequest(request, response, next); + // When in development passthrough auth token for ease of development. // Or if the user simply did not set an Auth token or JWT Secret if ( @@ -40,6 +47,37 @@ function validatedRequest(request, response, next) { next(); } +async function validateMultiUserRequest(request, response, next) { + const auth = request.header("Authorization"); + const token = auth ? auth.split(" ")[1] : null; + + if (!token) { + response.status(403).json({ + error: "No auth token found.", + }); + return; + } + + const valid = decodeJWT(token); + if (!valid || !valid.id) { + response.status(403).json({ + error: "Invalid auth token.", + }); + return; + } + + const user = await User.get(`id = ${valid.id}`); + if (!user) { + response.status(403).json({ + error: "Invalid auth for user.", + }); + return; + } + + response.locals.user = user; + next(); +} + module.exports = { validatedRequest, }; diff --git a/server/utils/vectorDbProviders/chroma/index.js b/server/utils/vectorDbProviders/chroma/index.js index 863b6f13..801a41db 100644 --- a/server/utils/vectorDbProviders/chroma/index.js +++ b/server/utils/vectorDbProviders/chroma/index.js @@ -44,6 +44,11 @@ const Chroma = { } return totalVectors; }, + namespaceCount: async function (_namespace = null) { + const { client } = await this.connect(); + const namespace = await this.namespace(client, _namespace); + return namespace?.vectorCount || 0; + }, embeddingFunc: function () { return new OpenAIEmbeddingFunction({ openai_api_key: process.env.OPEN_AI_KEY, diff --git a/server/utils/vectorDbProviders/lance/index.js b/server/utils/vectorDbProviders/lance/index.js index 3315028a..ddc18469 100644 --- a/server/utils/vectorDbProviders/lance/index.js +++ b/server/utils/vectorDbProviders/lance/index.js @@ -58,6 +58,14 @@ const LanceDb = { } return count; }, + namespaceCount: async function (_namespace = null) { + const { client } = await this.connect(); + const exists = await this.namespaceExists(client, _namespace); + if (!exists) return 0; + + const table = await client.openTable(_namespace); + return (await table.countRows()) || 0; + }, embeddingFunc: function () { return new lancedb.OpenAIEmbeddingFunction( "context", diff --git a/server/utils/vectorDbProviders/pinecone/index.js b/server/utils/vectorDbProviders/pinecone/index.js index 0c03e75b..e34391b1 100644 --- a/server/utils/vectorDbProviders/pinecone/index.js +++ b/server/utils/vectorDbProviders/pinecone/index.js @@ -86,6 +86,11 @@ const Pinecone = { 0 ); }, + namespaceCount: async function (_namespace = null) { + const { pineconeIndex } = await this.connect(); + const namespace = await this.namespace(pineconeIndex, _namespace); + return namespace?.vectorCount || 0; + }, similarityResponse: async function (index, namespace, queryVector) { const result = { contextTexts: [], diff --git a/server/yarn.lock b/server/yarn.lock index d5cef639..1a82497e 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -43,7 +43,7 @@ dependencies: googleapis-common "^6.0.3" -"@mapbox/node-pre-gyp@^1.0.0": +"@mapbox/node-pre-gyp@^1.0.0", "@mapbox/node-pre-gyp@^1.0.10": version "1.0.11" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== @@ -308,6 +308,14 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +bcrypt@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.0.tgz#bbb27665dbc400480a524d8991ac7434e8529e17" + integrity sha512-RHBS7HI5N5tEnGTmtR/pppX0mmDSBpQ4aCBsj7CEQfYXDcO74A8sIBYcJMuCsis2E81zDxeENYhv66oZwLiA+Q== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.10" + node-addon-api "^5.0.0" + bignumber.js@^9.0.0: version "9.1.1" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6" @@ -515,6 +523,11 @@ color-support@^1.1.2, color-support@^1.1.3: resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== +colors@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -542,6 +555,11 @@ command-line-usage@6.1.3: table-layout "^1.0.2" typical "^5.2.0" +commander@^8.0.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + compress-commons@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.1.tgz#df2a09a7ed17447642bad10a85cc9a19e5c42a7d" @@ -705,6 +723,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +encode32@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/encode32/-/encode32-1.1.0.tgz#0c54b45fb314ad5502e3c230cb95acdc5e5cd1dd" + integrity sha512-BCmijZ4lWec5+fuGHclf7HSZf+mos2ncQkBjtvomvRWVEGAMI/tw56fuN2x4e+FTgQuTPbZjODPwX80lFy958w== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -1654,6 +1677,11 @@ node-addon-api@^4.2.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== +node-addon-api@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" + integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7, node-fetch@^2.6.9: version "2.6.12" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba" @@ -2340,6 +2368,21 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +uuid-apikey@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/uuid-apikey/-/uuid-apikey-1.5.3.tgz#2e5d648dce93d2909018d7b73ec26ecb9fd2cdbd" + integrity sha512-v28vGJ1hRDzqLm6ufZ7b098Kmk159PInIHYWXfB47r3xOACZ5nRIAWe9VxFjvSW0MwckQYAnS1ucWUAXGKo95w== + dependencies: + colors "^1.4.0" + commander "^8.0.0" + encode32 "^1.1.0" + uuid "^8.3.1" + +uuid@^8.3.1: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + uuid@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"