diff --git a/README.md b/README.md index ceca8dbed..899b96f38 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,24 @@ -# 🤖 AnythingLLM: A full-stack personalized AI assistant +<p align="center"> + <b>🤖 AnythingLLM: A full-stack personalized AI assistant</b>. <br /> + A hyper-efficient and open-source document chatbot solution for all. +</p> -[](https://twitter.com/tcarambat) [](https://discord.gg/6UyHPeGZAC) +<p align="center"> + <a href="https://twitter.com/tcarambat" target="_blank"> + <img src="https://img.shields.io/twitter/url/https/twitter.com/tim.svg?style=social&label=Follow%20%40Timothy%20Carambat" alt="Twitter"> + </a> | + <a href="https://discord.gg/6UyHPeGZAC" target="_blank"> + <img src="https://dcbadge.vercel.app/api/server/6UyHPeGZAC?compact=true&style=flat" alt="Discord"> + </a> | + <a href="https://github.com/Mintplex-Labs/anything-llm/blob/master/LICENSE" target="_blank"> + <img src="https://img.shields.io/static/v1?label=license&message=MIT&color=white" alt="License"> + </a> | + <a href="https://docs.mintplex.xyz/anythingllm-by-mintplex-labs/" target="_blank"> + Docs + </a> +</p> -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.  @@ -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 a5aad549d..41ef50324 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 c29c66936..ca66bdc95 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() { <ContextWrapper> <Routes> <Route path="/" element={<Main />} /> - <Route path="/workspace/:slug" element={<WorkspaceChat />} /> + <Route + path="/workspace/:slug" + element={<PrivateRoute Component={WorkspaceChat} />} + /> + <Route path="/accept-invite/:code" element={<InvitePage />} /> + + {/* Admin Routes */} + <Route + path="/admin/system-preferences" + element={<AdminRoute Component={AdminSystem} />} + /> + <Route + path="/admin/invites" + element={<AdminRoute Component={AdminInvites} />} + /> + <Route + path="/admin/users" + element={<AdminRoute Component={AdminUsers} />} + /> + <Route + path="/admin/workspaces" + element={<AdminRoute Component={AdminWorkspaces} />} + /> + <Route + path="/admin/workspace-chats" + element={<AdminRoute Component={AdminChats} />} + /> </Routes> </ContextWrapper> </Suspense> diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx index 4b7f820ac..219fbea40 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 000000000..6c8b8f8c8 --- /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 ( + <> + <div + ref={sidebarRef} + style={{ height: "calc(100% - 32px)" }} + className="transition-all duration-500 relative m-[16px] rounded-[26px] bg-white dark:bg-black-900 min-w-[15.5%] p-[18px] " + > + <div className="w-full h-full flex flex-col overflow-x-hidden items-between"> + {/* Header Information */} + <div className="flex w-full items-center justify-between"> + <p className="text-xl font-base text-slate-600 dark:text-slate-200"> + AnythingLLM Admin + </p> + <div className="flex gap-x-2 items-center text-slate-500"> + <a + href={paths.home()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-stone-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" + > + <X className="h-4 w-4" /> + </a> + </div> + </div> + + {/* Primary Body */} + <div className="h-[100%] flex flex-col w-full justify-between pt-4 overflow-y-hidden"> + <div className="h-auto sidebar-items dark:sidebar-items"> + <div className="flex flex-col gap-y-4 h-[65vh] pb-8 overflow-y-scroll no-scroll"> + <Option + href={paths.admin.system()} + btnText="System Preferences" + icon={<Settings className="h-4 w-4 flex-shrink-0" />} + /> + <Option + href={paths.admin.invites()} + btnText="Invitation Management" + icon={<Mail className="h-4 w-4 flex-shrink-0" />} + /> + <Option + href={paths.admin.users()} + btnText="User Management" + icon={<Users className="h-4 w-4 flex-shrink-0" />} + /> + <Option + href={paths.admin.workspaces()} + btnText="Workspace Management" + icon={<BookOpen className="h-4 w-4 flex-shrink-0" />} + /> + <Option + href={paths.admin.chats()} + btnText="Workspace Chat Management" + icon={<MessageSquare className="h-4 w-4 flex-shrink-0" />} + /> + </div> + </div> + <div> + <div className="flex flex-col gap-y-2"> + <div className="w-full flex items-center justify-between"> + <LLMStatus /> + <IndexCount /> + </div> + </div> + + {/* Footer */} + <div className="flex items-end justify-between mt-2"> + <div className="flex gap-x-1 items-center"> + <a + href={paths.github()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-slate-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" + > + <GitHub className="h-4 w-4 " /> + </a> + <a + href={paths.docs()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-slate-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" + > + <BookOpen className="h-4 w-4 " /> + </a> + <a + href={paths.discord()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 dark:bg-slate-800 hover:bg-slate-800 group" + > + <Discord className="h-4 w-4 stroke-slate-400 group-hover:stroke-slate-200 dark:group-hover:stroke-slate-200" /> + </a> + </div> + <a + href={paths.mailToMintplex()} + className="transition-all duration-300 text-xs text-slate-200 dark:text-slate-600 hover:text-blue-600 dark:hover:text-blue-400" + > + @MintplexLabs + </a> + </div> + </div> + </div> + </div> + </div> + </> + ); +} + +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 ( + <> + <div className="flex justify-between relative top-0 left-0 w-full rounded-b-lg px-2 pb-4 bg-white dark:bg-black-900 text-slate-800 dark:text-slate-200"> + <button + onClick={() => setShowSidebar(true)} + className="rounded-md bg-stone-200 p-2 flex items-center justify-center text-slate-800 hover:bg-stone-300 group dark:bg-stone-800 dark:text-slate-200 dark:hover:bg-stone-900 dark:border dark:border-stone-800" + > + <Menu className="h-6 w-6" /> + </button> + <p className="text-xl font-base text-slate-600 dark:text-slate-200"> + AnythingLLM + </p> + </div> + <div + style={{ + transform: showSidebar ? `translateX(0vw)` : `translateX(-100vw)`, + }} + className={`z-99 fixed top-0 left-0 transition-all duration-500 w-[100vw] h-[100vh]`} + > + <div + className={`${ + showBgOverlay + ? "transition-all opacity-1" + : "transition-none opacity-0" + } duration-500 fixed top-0 left-0 bg-black-900 bg-opacity-75 w-screen h-screen`} + onClick={() => setShowSidebar(false)} + /> + <div + ref={sidebarRef} + className="h-[100vh] fixed top-0 left-0 rounded-r-[26px] bg-white dark:bg-black-900 w-[70%] p-[18px] " + > + <div className="w-full h-full flex flex-col overflow-x-hidden items-between"> + {/* Header Information */} + <div className="flex w-full items-center justify-between"> + <p className="text-xl font-base text-slate-600 dark:text-slate-200"> + AnythingLLM Admin + </p> + <div className="flex gap-x-2 items-center text-slate-500"> + <a + href={paths.home()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-stone-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" + > + <X className="h-4 w-4" /> + </a> + </div> + </div> + + {/* Primary Body */} + <div className="h-full flex flex-col w-full justify-between pt-4 overflow-y-hidden "> + <div className="h-auto md:sidebar-items md:dark:sidebar-items"> + <div + style={{ height: "calc(100vw - -3rem)" }} + className=" flex flex-col gap-y-4 pb-8 overflow-y-scroll no-scroll" + > + <Option + href={paths.admin.users()} + btnText="User Management" + icon={<Users className="h-4 w-4 flex-shrink-0" />} + /> + </div> + </div> + <div> + <div className="flex flex-col gap-y-2"> + <div className="w-full flex items-center justify-between"> + <LLMStatus /> + <IndexCount /> + </div> + </div> + + {/* Footer */} + <div className="flex items-end justify-between mt-2"> + <div className="flex gap-x-1 items-center"> + <a + href={paths.github()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-slate-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" + > + <GitHub className="h-4 w-4 " /> + </a> + <a + href={paths.docs()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-slate-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" + > + <BookOpen className="h-4 w-4 " /> + </a> + <a + href={paths.discord()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 dark:bg-slate-800 hover:bg-slate-800 group" + > + <Discord className="h-4 w-4 stroke-slate-400 group-hover:stroke-slate-200 dark:group-hover:stroke-slate-200" /> + </a> + </div> + <a + href={paths.mailToMintplex()} + className="transition-all duration-300 text-xs text-slate-200 dark:text-slate-600 hover:text-blue-600 dark:hover:text-blue-400" + > + @MintplexLabs + </a> + </div> + </div> + </div> + </div> + </div> + </div> + </> + ); +} + +const Option = ({ btnText, icon, href }) => { + const isActive = window.location.pathname === href; + return ( + <div className="flex gap-x-2 items-center justify-between"> + <a + href={href} + className={`flex flex-grow w-[75%] h-[36px] gap-x-2 py-[5px] px-4 border border-slate-400 rounded-lg text-slate-800 dark:text-slate-200 justify-start items-center ${ + isActive + ? "bg-gray-100 dark:bg-stone-600" + : "hover:bg-slate-100 dark:hover:bg-stone-900 " + }`} + > + {icon} + <p className="text-slate-800 dark:text-slate-200 text-xs leading-loose font-semibold whitespace-nowrap overflow-hidden "> + {btnText} + </p> + </a> + </div> + ); +}; diff --git a/frontend/src/components/Modals/Password.jsx b/frontend/src/components/Modals/Password.jsx deleted file mode 100644 index 30c628fbb..000000000 --- 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 ( - <div className="fixed top-0 left-0 right-0 z-50 w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] h-full bg-black bg-opacity-50 flex items-center justify-center"> - <div className="flex fixed top-0 left-0 right-0 w-full h-full" /> - <div className="relative w-full max-w-2xl max-h-full"> - <form ref={formEl} onSubmit={handleLogin}> - <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> - <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> - <h3 className="text-md md:text-xl font-semibold text-gray-900 dark:text-white"> - This workspace is password protected. - </h3> - </div> - <div className="p-6 space-y-6 flex h-full w-full"> - <div className="w-full flex flex-col gap-y-4"> - <div> - <label - htmlFor="password" - className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" - > - Workspace Password - </label> - <input - name="password" - type="password" - id="password" - 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" - required={true} - autoComplete="off" - /> - </div> - {error && ( - <p className="text-red-600 dark:text-red-400 text-sm"> - Error: {error} - </p> - )} - <p className="text-gray-800 dark:text-slate-200 md:text-sm text-xs"> - You will only have to enter this password once. After - successful login it will be stored in your browser. - </p> - </div> - </div> - <div className="flex items-center justify-end p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> - <button - disabled={loading} - type="submit" - className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" - > - {loading ? "Validating..." : "Submit"} - </button> - </div> - </div> - </form> - </div> - </div> - ); -} - -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 000000000..630c01caa --- /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 ( + <form onSubmit={handleLogin}> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-md md:text-xl font-semibold text-gray-900 dark:text-white"> + This instance is password protected. + </h3> + </div> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div> + <label + htmlFor="username" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Instance Username + </label> + <input + name="username" + 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" + required={true} + autoComplete="off" + /> + </div> + + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Instance Password + </label> + <input + name="password" + type="password" + 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" + required={true} + autoComplete="off" + /> + </div> + {error && ( + <p className="text-red-600 dark:text-red-400 text-sm"> + Error: {error} + </p> + )} + <p className="text-gray-800 dark:text-slate-200 md:text-sm text-xs"> + You will only have to enter this password once. After successful + login it will be stored in your browser. + </p> + </div> + </div> + <div className="flex items-center justify-end p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + <button + disabled={loading} + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" + > + {loading ? "Validating..." : "Submit"} + </button> + </div> + </div> + </form> + ); +} diff --git a/frontend/src/components/Modals/Password/SingleUserAuth.jsx b/frontend/src/components/Modals/Password/SingleUserAuth.jsx new file mode 100644 index 000000000..c930289a6 --- /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 ( + <form onSubmit={handleLogin}> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-md md:text-xl font-semibold text-gray-900 dark:text-white"> + This workspace is password protected. + </h3> + </div> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Workspace Password + </label> + <input + name="password" + type="password" + id="password" + 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" + required={true} + autoComplete="off" + /> + </div> + {error && ( + <p className="text-red-600 dark:text-red-400 text-sm"> + Error: {error} + </p> + )} + <p className="text-gray-800 dark:text-slate-200 md:text-sm text-xs"> + You will only have to enter this password once. After successful + login it will be stored in your browser. + </p> + </div> + </div> + <div className="flex items-center justify-end p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + <button + disabled={loading} + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" + > + {loading ? "Validating..." : "Submit"} + </button> + </div> + </div> + </form> + ); +} diff --git a/frontend/src/components/Modals/Password/index.jsx b/frontend/src/components/Modals/Password/index.jsx new file mode 100644 index 000000000..ec5a0b44e --- /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 ( + <div className="fixed top-0 left-0 right-0 z-50 w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] h-full bg-black bg-opacity-50 flex items-center justify-center"> + <div className="flex fixed top-0 left-0 right-0 w-full h-full" /> + <div className="relative w-full max-w-2xl max-h-full"> + {mode === "single" ? <SingleUserAuth /> : <MultiUserAuth />} + </div> + </div> + ); +} + +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 77e1a2f4b..36709c39b 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 ( <div className="relative w-full max-w-2xl max-h-full"> @@ -40,83 +32,75 @@ export default function SystemKeys({ hideModal = noop }) { </p> </div> <div className="p-6 space-y-6 flex h-full w-full"> - {loading ? ( - <div className="w-full h-full flex items-center justify-center"> - <p className="text-gray-800 dark:text-gray-200 text-base"> - loading system settings - </p> - </div> - ) : ( - <div className="w-full flex flex-col gap-y-4"> - {!validSettings(settings) && ( - <div className="bg-orange-300 p-4 rounded-lg border border-orange-600 text-orange-700 w-full items-center flex gap-x-2"> - <AlertCircle className="h-8 w-8" /> - <p className="text-sm md:text-base "> - Ensure all fields are green before attempting to use - AnythingLLM or it may not function as expected! - </p> - </div> - )} - <ShowKey - name="OpenAI API Key" - env="OpenAiKey" - value={settings?.OpenAiKey ? "*".repeat(20) : ""} - valid={settings?.OpenAiKey} - allowDebug={settings?.CanDebug} - /> - <ShowKey - name="OpenAI Model for chats" - env="OpenAiModelPref" - value={settings?.OpenAiModelPref} - valid={!!settings?.OpenAiModelPref} - allowDebug={settings?.CanDebug} - /> - <div className="h-[2px] w-full bg-gray-200 dark:bg-stone-600" /> - <ShowKey - name="Vector DB Choice" - env="VectorDB" - value={settings?.VectorDB} - valid={!!settings?.VectorDB} - allowDebug={settings?.CanDebug} - /> - {settings?.VectorDB === "pinecone" && ( - <> - <ShowKey - name="Pinecone DB API Key" - env="PineConeKey" - value={settings?.PineConeKey ? "*".repeat(20) : ""} - valid={!!settings?.PineConeKey} - allowDebug={settings?.CanDebug} - /> - <ShowKey - name="Pinecone DB Environment" - env="PineConeEnvironment" - value={settings?.PineConeEnvironment} - valid={!!settings?.PineConeEnvironment} - allowDebug={settings?.CanDebug} - /> - <ShowKey - name="Pinecone DB Index" - env="PineConeIndex" - value={settings?.PineConeIndex} - valid={!!settings?.PineConeIndex} - allowDebug={settings?.CanDebug} - /> - </> - )} - {settings?.VectorDB === "chroma" && ( - <> - <ShowKey - name="Chroma Endpoint" - env="ChromaEndpoint" - value={settings?.ChromaEndpoint} - valid={!!settings?.ChromaEndpoint} - allowDebug={settings?.CanDebug} - /> - </> - )} - </div> - )} + <div className="w-full flex flex-col gap-y-4"> + {!validSettings(settings) && ( + <div className="bg-orange-300 p-4 rounded-lg border border-orange-600 text-orange-700 w-full items-center flex gap-x-2"> + <AlertCircle className="h-8 w-8" /> + <p className="text-sm md:text-base "> + Ensure all fields are green before attempting to use + AnythingLLM or it may not function as expected! + </p> + </div> + )} + <ShowKey + name="OpenAI API Key" + env="OpenAiKey" + value={settings?.OpenAiKey ? "*".repeat(20) : ""} + valid={settings?.OpenAiKey} + allowDebug={canDebug} + /> + <ShowKey + name="OpenAI Model for chats" + env="OpenAiModelPref" + value={settings?.OpenAiModelPref} + valid={!!settings?.OpenAiModelPref} + allowDebug={canDebug} + /> + <div className="h-[2px] w-full bg-gray-200 dark:bg-stone-600" /> + <ShowKey + name="Vector DB Choice" + env="VectorDB" + value={settings?.VectorDB} + valid={!!settings?.VectorDB} + allowDebug={canDebug} + /> + {settings?.VectorDB === "pinecone" && ( + <> + <ShowKey + name="Pinecone DB API Key" + env="PineConeKey" + value={settings?.PineConeKey ? "*".repeat(20) : ""} + valid={!!settings?.PineConeKey} + allowDebug={canDebug} + /> + <ShowKey + name="Pinecone DB Environment" + env="PineConeEnvironment" + value={settings?.PineConeEnvironment} + valid={!!settings?.PineConeEnvironment} + allowDebug={canDebug} + /> + <ShowKey + name="Pinecone DB Index" + env="PineConeIndex" + value={settings?.PineConeIndex} + valid={!!settings?.PineConeIndex} + allowDebug={canDebug} + /> + </> + )} + {settings?.VectorDB === "chroma" && ( + <> + <ShowKey + name="Chroma Endpoint" + env="ChromaEndpoint" + value={settings?.ChromaEndpoint} + valid={!!settings?.ChromaEndpoint} + allowDebug={canDebug} + /> + </> + )} + </div> </div> <div className="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> <button @@ -142,7 +126,7 @@ function ShowKey({ name, env, value, valid, allowDebug = true }) { const data = {}; const form = new FormData(e.target); for (var [key, value] of form.entries()) data[key] = value; - const { newValues, error } = await System.updateSystem(data); + const { error } = await System.updateSystem(data); if (!!error) { alert(error); setSaving(false); @@ -212,7 +196,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 </button> )} </> @@ -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 </button> )} </div> 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 000000000..6a8b96e9e --- /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 ( + <div className="relative w-full max-w-2xl max-h-full"> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between px-6 py-4"> + <p className="text-gray-800 dark:text-stone-200 text-base "> + 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. + </p> + </div> + {(error || success) && ( + <div className="w-full flex px-6"> + {error && ( + <div className="w-full bg-red-300 text-red-800 font-semibold px-4 py-2 rounded-lg"> + {error} + </div> + )} + {success && ( + <div className="w-full bg-green-300 text-green-800 font-semibold px-4 py-2 rounded-lg"> + Your page will refresh in a few seconds. + </div> + )} + </div> + )} + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <form onSubmit={handleSubmit}> + <div className=""> + <label className="mb-2.5 block font-medium text-black dark:text-white"> + Enable Multi-User Mode + </label> + + <label className="relative inline-flex cursor-pointer items-center"> + <input + type="checkbox" + onClick={() => setUseMultiUserMode(!useMultiUserMode)} + checked={useMultiUserMode} + className="peer sr-only pointer-events-none" + /> + <div className="pointer-events-none peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-green-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-stone-400 dark:peer-focus:ring-blue-800"></div> + </label> + </div> + <div className="w-full flex flex-col gap-y-2 my-2"> + {useMultiUserMode && ( + <> + <p className="text-gray-800 dark:text-stone-200 text-sm bg-gray-200 dark:bg-stone-800 rounded-lg p-4"> + 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. + </p> + <div> + <label + htmlFor="username" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Admin account username + </label> + <input + name="username" + 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 admin username" + minLength={2} + required={true} + autoComplete="off" + /> + </div> + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Admin account password + </label> + <input + 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 admin password" + minLength={8} + required={true} + autoComplete="off" + /> + </div> + <button + disabled={saving} + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" + > + {saving ? "Enabling..." : "Enable Multi-User mode"} + </button> + </> + )} + </div> + </form> + </div> + </div> + <div className="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + <button + onClick={hideModal} + type="button" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" + > + Close + </button> + </div> + </div> + </div> + ); +} diff --git a/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx b/frontend/src/components/Modals/Settings/PasswordProtection/index.jsx index 2b6444edb..387c44bc6 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 ( <div className="relative w-full max-w-2xl max-h-full"> <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> @@ -69,62 +64,54 @@ export default function PasswordProtection({ hideModal = noop }) { </div> )} <div className="p-6 space-y-6 flex h-full w-full"> - {loading ? ( - <div className="w-full h-full flex items-center justify-center"> - <p className="text-gray-800 dark:text-gray-200 text-base"> - loading system settings - </p> - </div> - ) : ( - <div className="w-full flex flex-col gap-y-4"> - <form onSubmit={handleSubmit}> - <div className=""> - <label className="mb-2.5 block font-medium text-black dark:text-white"> - Password Protect Instance - </label> + <div className="w-full flex flex-col gap-y-4"> + <form onSubmit={handleSubmit}> + <div className=""> + <label className="mb-2.5 block font-medium text-black dark:text-white"> + Password Protect Instance + </label> - <label className="relative inline-flex cursor-pointer items-center"> + <label className="relative inline-flex cursor-pointer items-center"> + <input + type="checkbox" + name="use_password" + onClick={() => setUsePassword(!usePassword)} + checked={usePassword} + className="peer sr-only pointer-events-none" + /> + <div className="pointer-events-none peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-green-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-stone-400 dark:peer-focus:ring-blue-800"></div> + </label> + </div> + <div className="w-full flex flex-col gap-y-2 my-2"> + {usePassword && ( + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + New Password + </label> <input - type="checkbox" - name="use_password" - onClick={() => 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" /> - <div className="pointer-events-none peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-green-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-stone-400 dark:peer-focus:ring-blue-800"></div> - </label> - </div> - <div className="w-full flex flex-col gap-y-2 my-2"> - {usePassword && ( - <div> - <label - htmlFor="password" - className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" - > - New Password - </label> - <input - 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" - /> - </div> - )} - <button - disabled={saving} - type="submit" - className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" - > - {saving ? "Saving..." : "Save Changes"} - </button> - </div> - </form> - </div> - )} + </div> + )} + <button + disabled={saving} + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600" + > + {saving ? "Saving..." : "Save Changes"} + </button> + </div> + </form> + </div> </div> <div className="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> <button diff --git a/frontend/src/components/Modals/Settings/index.jsx b/frontend/src/components/Modals/Settings/index.jsx index e898a7afa..9d02d94e8 100644 --- a/frontend/src/components/Modals/Settings/index.jsx +++ b/frontend/src/components/Modals/Settings/index.jsx @@ -1,20 +1,38 @@ -import React, { useState } from "react"; -import { Archive, Lock, Key, X } from "react-feather"; +import React, { useEffect, useState } from "react"; +import { Archive, Lock, Key, X, Users, LogOut } from "react-feather"; import SystemKeys from "./Keys"; import ExportOrImportData from "./ExportImport"; import PasswordProtection from "./PasswordProtection"; +import System from "../../../models/system"; +import MultiUserMode from "./MultiUserMode"; +import { AUTH_TOKEN, AUTH_USER } from "../../../utils/constants"; +import paths from "../../../utils/paths"; +import useUser from "../../../hooks/useUser"; const TABS = { keys: SystemKeys, exportimport: ExportOrImportData, password: PasswordProtection, + multiuser: MultiUserMode, }; const noop = () => false; export default function SystemSettingsModal({ hideModal = noop }) { + const { user } = useUser(); + const [loading, setLoading] = useState(true); const [selectedTab, setSelectedTab] = useState("keys"); + const [settings, setSettings] = useState(null); const Component = TABS[selectedTab || "keys"]; + useEffect(() => { + async function fetchKeys() { + const _settings = await System.keys(); + setSettings(_settings); + setLoading(false); + } + fetchKeys(); + }, []); + return ( <div className="fixed top-0 left-0 right-0 z-50 w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] h-full bg-black bg-opacity-50 flex items-center justify-center"> <div @@ -37,42 +55,72 @@ export default function SystemSettingsModal({ hideModal = noop }) { <X className="text-gray-300 text-lg" /> </button> </div> - <SettingTabs selectedTab={selectedTab} changeTab={setSelectedTab} /> + <SettingTabs + selectedTab={selectedTab} + changeTab={setSelectedTab} + settings={settings} + user={user} + /> </div> - <Component hideModal={hideModal} /> + {loading ? ( + <div className="w-full flex h-[400px] p-6"> + <div className="w-full flex h-full bg-gray-200 dark:bg-stone-600 animate-pulse rounded-lg" /> + </div> + ) : ( + <Component hideModal={hideModal} user={user} settings={settings} /> + )} </div> </div> </div> ); } -function SettingTabs({ selectedTab, changeTab }) { +function SettingTabs({ selectedTab, changeTab, settings, user }) { + if (!settings) { + return ( + <div className="w-full flex h-[60px] pb-2"> + <div className="w-full flex h-full bg-gray-200 dark:bg-stone-600 animate-pulse rounded-lg" /> + </div> + ); + } + return ( - <div> - <ul className="flex md:flex-wrap overflow-x-scroll no-scroll -mb-px text-sm gap-x-2 font-medium text-center text-gray-500 dark:text-gray-400"> - <SettingTab - active={selectedTab === "keys"} - displayName="Keys" - tabName="keys" - icon={<Key className="h-4 w-4 flex-shrink-0" />} - onClick={changeTab} - /> - <SettingTab - active={selectedTab === "exportimport"} - displayName="Export or Import" - tabName="exportimport" - icon={<Archive className="h-4 w-4 flex-shrink-0" />} - onClick={changeTab} - /> - <SettingTab - active={selectedTab === "password"} - displayName="Password Protection" - tabName="password" - icon={<Lock className="h-4 w-4 flex-shrink-0" />} - onClick={changeTab} - /> - </ul> - </div> + <ul className="flex overflow-x-scroll no-scroll -mb-px text-sm gap-x-2 font-medium text-center text-gray-500 dark:text-gray-400"> + <SettingTab + active={selectedTab === "keys"} + displayName="Keys" + tabName="keys" + icon={<Key className="h-4 w-4 flex-shrink-0" />} + onClick={changeTab} + /> + <SettingTab + active={selectedTab === "exportimport"} + displayName="Export or Import" + tabName="exportimport" + icon={<Archive className="h-4 w-4 flex-shrink-0" />} + onClick={changeTab} + /> + {!settings?.MultiUserMode ? ( + <> + <SettingTab + active={selectedTab === "multiuser"} + displayName="Multi User Mode" + tabName="multiuser" + icon={<Users className="h-4 w-4 flex-shrink-0" />} + onClick={changeTab} + /> + <SettingTab + active={selectedTab === "password"} + displayName="Password Protection" + tabName="password" + icon={<Lock className="h-4 w-4 flex-shrink-0" />} + onClick={changeTab} + /> + </> + ) : ( + <LogoutTab user={user} /> + )} + </ul> ); } @@ -102,6 +150,25 @@ function SettingTab({ ); } +function LogoutTab({ user }) { + if (!user) return null; + + return ( + <li className="mr-2"> + <button + onClick={() => { + window.localStorage.removeItem(AUTH_USER); + window.localStorage.removeItem(AUTH_TOKEN); + window.location.replace(paths.home()); + }} + className="flex items-center gap-x-1 p-4 border-b-2 rounded-t-lg group whitespace-nowrap border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300" + > + <LogOut className="h-4 w-4 flex-shrink-0" /> Log out of {user.username} + </button> + </li> + ); +} + 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 000000000..728f41bfc --- /dev/null +++ b/frontend/src/components/Preloader.jsx @@ -0,0 +1,16 @@ +export default function PreLoader() { + return ( + <div className="h-16 w-16 animate-spin rounded-full border-4 border-solid border-primary border-t-transparent"></div> + ); +} + +export function FullScreenLoader() { + return ( + <div + id="preloader" + className="fixed left-0 top-0 z-999999 flex h-screen w-screen items-center justify-center bg-white dark:bg-stone-800" + > + <div className="h-16 w-16 animate-spin rounded-full border-4 border-solid border-primary border-t-transparent"></div> + </div> + ); +} diff --git a/frontend/src/components/PrivateRoute/index.jsx b/frontend/src/components/PrivateRoute/index.jsx new file mode 100644 index 000000000..33f5c6337 --- /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 <FullScreenLoader />; + + const user = userFromStorage(); + return authed && user?.role === "admin" ? ( + <Component /> + ) : ( + <Navigate to={paths.home()} /> + ); +} + +export default function PrivateRoute({ Component }) { + const authed = useIsAuthenticated(); + if (authed === null) return <FullScreenLoader />; + + return authed ? <Component /> : <Navigate to={paths.home()} />; +} diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx index 6abdd51d4..b12872824 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 </p> <div className="flex gap-x-2 items-center text-slate-500"> + <AdminHome /> <button onClick={showSystemSettingsModal} className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-stone-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" @@ -144,9 +147,9 @@ export default function Sidebar() { } export function SidebarMobileHeader() { + const sidebarRef = useRef(null); const [showSidebar, setShowSidebar] = useState(false); const [showBgOverlay, setShowBgOverlay] = useState(false); - const sidebarRef = useRef(null); const { showing: showingSystemSettingsModal, showModal: showSystemSettingsModal, @@ -209,6 +212,7 @@ export function SidebarMobileHeader() { AnythingLLM </p> <div className="flex gap-x-2 items-center text-slate-500"> + <AdminHome /> <button onClick={showSystemSettingsModal} className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-stone-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" @@ -308,3 +312,16 @@ export function SidebarMobileHeader() { </> ); } + +function AdminHome() { + const { user } = useUser(); + if (!user || user?.role !== "admin") return null; + return ( + <a + href={paths.admin.system()} + className="transition-all duration-300 p-2 rounded-full bg-slate-200 text-slate-400 dark:bg-stone-800 hover:bg-slate-800 hover:text-slate-200 dark:hover:text-slate-200" + > + <Shield className="h-4 w-4" /> + </a> + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index d13f670f5..ee5af67ec 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -4,6 +4,7 @@ import Jazzicon from "../../../../UserIcon"; import { v4 } from "uuid"; import { decode as HTMLDecode } from "he"; import renderMarkdown from "../../../../../utils/chat/markdown"; +import { userFromStorage } from "../../../../../utils/request"; function HistoricalMessage({ message, @@ -28,7 +29,7 @@ function HistoricalMessage({ {message} </span> </div> - <Jazzicon size={30} user={{ uid: "user" }} /> + <Jazzicon size={30} user={{ uid: userFromStorage()?.username }} /> </div> ); } diff --git a/frontend/src/hooks/usePrefersDarkMode.js b/frontend/src/hooks/usePrefersDarkMode.js new file mode 100644 index 000000000..2c14e810d --- /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 000000000..2af24ed48 --- /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 000000000..c3feb04bb --- /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 1a7212337..225a8d457 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 000000000..8aedf810b --- /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 000000000..c5f3e3c4b --- /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 7e5f61bfd..43c0013f2 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 e8aa0bce1..ac61c7186 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 000000000..ca0d5c781 --- /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 ( + <> + <tr ref={rowRef} className="bg-transparent"> + <td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {chat.id} + </td> + <td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {chat.user?.username} + </td> + <td className="px-6 py-4 font-mono">{chat.workspace?.name}</td> + <td + onClick={() => { + 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)} + </td> + <td + onClick={() => { + 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)} + </td> + <td className="px-6 py-4">{chat.createdAt}</td> + <td className="px-6 py-4 flex items-center gap-x-6"> + <button + onClick={handleDelete} + className="font-medium text-red-600 dark:text-red-300 px-2 py-1 rounded-lg hover:bg-red-50 hover:dark:bg-red-800 hover:dark:bg-opacity-20" + > + Delete + </button> + </td> + </tr> + <TextPreview text={chat.prompt} modalName={`chat-${chat.id}-prompt`} /> + <TextPreview + text={JSON.parse(chat.response)?.text} + modalName={`chat-${chat.id}-response`} + /> + </> + ); +} + +function hideModal(modalName) { + document.getElementById(modalName)?.close(); +} + +const TextPreview = ({ text, modalName }) => { + return ( + <dialog id={modalName} className="bg-transparent outline-none w-full"> + <div className="relative w-full max-w-2xl max-h-full min-w-1/2"> + <div className="min-w-1/2 relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-xl font-semibold text-gray-900 dark:text-white"> + Viewing Text + </h3> + <button + onClick={() => hideModal(modalName)} + type="button" + className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white" + data-modal-hide="staticModal" + > + <X className="text-gray-300 text-lg" /> + </button> + </div> + <div className="w-full p-4 w-full flex"> + <pre className="w-full flex h-[200px] py-2 px-4 overflow-scroll rounded-lg bg-stone-400 bg-gray-200 text-gray-800 dark:text-slate-800 font-mono"> + {text} + </pre> + </div> + </div> + </div> + </dialog> + ); +}; diff --git a/frontend/src/pages/Admin/Chats/index.jsx b/frontend/src/pages/Admin/Chats/index.jsx new file mode 100644 index 000000000..8c439bb70 --- /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 ( + <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> + {!isMobile && <Sidebar />} + <div + style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }} + className="transition-all duration-500 relative md:ml-[2px] md:mr-[8px] md:my-[16px] md:rounded-[26px] bg-white dark:bg-black-900 md:min-w-[82%] p-[18px] h-full overflow-y-scroll" + > + {isMobile && <SidebarMobileHeader />} + <div className="flex flex-col w-full px-1 md:px-8"> + <div className="w-full flex flex-col gap-y-1"> + <div className="items-center flex gap-x-4"> + <p className="text-3xl font-semibold text-slate-600 dark:text-slate-200"> + Workspace Chats + </p> + </div> + <p className="text-sm font-base text-slate-600 dark:text-slate-200"> + These are all the recorded chats and messages that have been sent + by users ordered by their creation date. + </p> + </div> + <ChatsContainer /> + </div> + </div> + </div> + ); +} + +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 ( + <Skeleton.default + height="80vh" + width="100%" + baseColor={darkMode ? "#2a3a53" : null} + highlightColor={darkMode ? "#395073" : null} + count={1} + className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6" + containerClassName="flex w-full" + /> + ); + } + + return ( + <> + <table className="md:w-full w-full text-sm text-left text-gray-500 dark:text-gray-400 rounded-lg mt-5"> + <thead className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-stone-800 dark:text-gray-400"> + <tr> + <th scope="col" className="px-6 py-3 rounded-tl-lg"> + Id + </th> + <th scope="col" className="px-6 py-3"> + Sent By + </th> + <th scope="col" className="px-6 py-3"> + Workspace + </th> + <th scope="col" className="px-6 py-3"> + Prompt + </th> + <th scope="col" className="px-6 py-3"> + Response + </th> + <th scope="col" className="px-6 py-3"> + Sent At + </th> + <th scope="col" className="px-6 py-3 rounded-tr-lg"> + Actions + </th> + </tr> + </thead> + <tbody> + {chats.map((chat) => ( + <ChatRow key={chat.id} chat={chat} /> + ))} + </tbody> + </table> + <div className="flex w-full justify-between items-center"> + <button + onClick={handlePrevious} + className="px-4 py-2 rounded-lg border border-gray-800 dark:border-slate-200 text-gray-800 text-slate-200 disabled:invisible" + disabled={offset === 0} + > + {" "} + Previous Page + </button> + <button + onClick={handleNext} + className="px-4 py-2 rounded-lg border border-gray-800 dark:border-slate-200 text-gray-800 text-slate-200 disabled:invisible" + disabled={!canNext} + > + Next Page + </button> + </div> + </> + ); +} 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 000000000..4c6a643e9 --- /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 ( + <> + <tr ref={rowRef} className="bg-transparent"> + <td + scope="row" + className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white font-mono" + > + {titleCase(status)} + </td> + <td className="px-6 py-4"> + {invite.claimedBy + ? invite.claimedBy?.username || "deleted user" + : "--"} + </td> + <td className="px-6 py-4"> + {invite.createdBy?.username || "deleted user"} + </td> + <td className="px-6 py-4">{invite.createdAt}</td> + <td className="px-6 py-4 flex items-center gap-x-6"> + {status === "pending" && ( + <> + <button + onClick={copyInviteLink} + disabled={copied} + className="font-medium text-blue-600 dark:text-blue-300 px-2 py-1 rounded-lg hover:bg-blue-50 hover:dark:bg-blue-800 hover:dark:bg-opacity-20" + > + {copied ? "Copied" : "Copy Invite Link"} + </button> + <button + onClick={handleDelete} + className="font-medium text-red-600 dark:text-red-300 px-2 py-1 rounded-lg hover:bg-red-50 hover:dark:bg-red-800 hover:dark:bg-opacity-20" + > + Deactivate + </button> + </> + )} + </td> + </tr> + </> + ); +} 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 000000000..6da534e21 --- /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 ( + <dialog id={DIALOG_ID} className="bg-transparent outline-none"> + <div className="relative w-full max-w-2xl max-h-full"> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-xl font-semibold text-gray-900 dark:text-white"> + Create new invite + </h3> + <button + onClick={hideModal} + type="button" + className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white" + data-modal-hide="staticModal" + > + <X className="text-gray-300 text-lg" /> + </button> + </div> + <form onSubmit={handleCreate}> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + {error && ( + <p className="text-red-600 dark:text-red-400 text-sm"> + Error: {error} + </p> + )} + {invite && ( + <input + type="url" + defaultValue={`${window.location.origin}/accept-invite/${invite.code}`} + disabled={true} + className="rounded-lg px-4 py-2 text-gray-800 bg-gray-100 dark:text-slate-200 dark:bg-stone-800" + /> + )} + <p className="text-gray-800 dark:text-slate-200 text-xs md:text-sm"> + 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. + </p> + </div> + </div> + <div className="flex w-full justify-between items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + {!invite ? ( + <> + <button + onClick={hideModal} + type="button" + className="text-gray-800 hover:bg-gray-100 px-4 py-1 rounded-lg dark:text-slate-200 dark:hover:bg-stone-900" + > + Cancel + </button> + <button + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-black dark:text-slate-200 dark:border-transparent dark:hover:text-slate-200 dark:hover:bg-gray-900 dark:focus:ring-gray-800" + > + Create Invite + </button> + </> + ) : ( + <button + onClick={copyInviteLink} + type="button" + disabled={copied} + className="w-full disabled:bg-green-200 disabled:text-green-600 text-gray-800 bg-gray-100 px-4 py-2 rounded-lg dark:text-slate-200 dark:bg-stone-900" + > + {copied ? "Copied Link" : "Copy Invite Link"} + </button> + )} + </div> + </form> + </div> + </div> + </dialog> + ); +} diff --git a/frontend/src/pages/Admin/Invitations/index.jsx b/frontend/src/pages/Admin/Invitations/index.jsx new file mode 100644 index 000000000..651275905 --- /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 ( + <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> + {!isMobile && <Sidebar />} + <div + style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }} + className="transition-all duration-500 relative md:ml-[2px] md:mr-[8px] md:my-[16px] md:rounded-[26px] bg-white dark:bg-black-900 md:min-w-[82%] p-[18px] h-full overflow-y-scroll" + > + {isMobile && <SidebarMobileHeader />} + <div className="flex flex-col w-full px-1 md:px-8"> + <div className="w-full flex flex-col gap-y-1"> + <div className="items-center flex gap-x-4"> + <p className="text-3xl font-semibold text-slate-600 dark:text-slate-200"> + Invitations + </p> + <button + onClick={() => + document?.getElementById(NewInviteModalId)?.showModal() + } + className="border border-slate-800 dark:border-slate-200 px-4 py-1 rounded-lg text-slate-800 dark:text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-800 hover:text-slate-100 dark:hover:bg-slate-200 dark:hover:text-slate-800" + > + <Mail className="h-4 w-4" /> Create Invite Link + </button> + </div> + <p className="text-sm font-base text-slate-600 dark:text-slate-200"> + Create invitation links for people in your organization to accept + and sign up with. Invitations can only be used by a single user. + </p> + </div> + <InvitationsContainer /> + </div> + <NewInviteModal /> + </div> + </div> + ); +} + +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 ( + <Skeleton.default + height="80vh" + width="100%" + baseColor={darkMode ? "#2a3a53" : null} + highlightColor={darkMode ? "#395073" : null} + count={1} + className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6" + containerClassName="flex w-full" + /> + ); + } + + return ( + <table className="md:w-3/4 w-full text-sm text-left text-gray-500 dark:text-gray-400 rounded-lg mt-5"> + <thead className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-stone-800 dark:text-gray-400"> + <tr> + <th scope="col" className="px-6 py-3"> + Status + </th> + <th scope="col" className="px-6 py-3 rounded-tl-lg"> + Accepted By + </th> + <th scope="col" className="px-6 py-3"> + Created By + </th> + <th scope="col" className="px-6 py-3"> + Created + </th> + <th scope="col" className="px-6 py-3 rounded-tr-lg"> + Actions + </th> + </tr> + </thead> + <tbody> + {invites.map((invite) => ( + <InviteRow key={invite.id} invite={invite} /> + ))} + </tbody> + </table> + ); +} diff --git a/frontend/src/pages/Admin/System/index.jsx b/frontend/src/pages/Admin/System/index.jsx new file mode 100644 index 000000000..58874b90b --- /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 ( + <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> + {!isMobile && <Sidebar />} + <div + style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }} + className="transition-all duration-500 relative md:ml-[2px] md:mr-[8px] md:my-[16px] md:rounded-[26px] bg-white dark:bg-black-900 md:min-w-[82%] p-[18px] h-full overflow-y-scroll" + > + {isMobile && <SidebarMobileHeader />} + <form + onSubmit={handleSubmit} + onChange={() => setHasChanges(true)} + className="flex w-full" + > + <div className="flex flex-col w-full px-1 md:px-8"> + <div className="w-full flex flex-col gap-y-1"> + <div className="items-center flex gap-x-4"> + <p className="text-3xl font-semibold text-slate-600 dark:text-slate-200"> + System Preferences + </p> + {hasChanges && ( + <button + type="submit" + disabled={saving} + className="border border-slate-800 dark:border-slate-200 px-4 py-1 rounded-lg text-slate-800 dark:text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-800 hover:text-slate-100 dark:hover:bg-slate-200 dark:hover:text-slate-800" + > + {saving ? "Saving..." : "Save changes"} + </button> + )} + </div> + <p className="text-sm font-base text-slate-600 dark:text-slate-200"> + These are the overall settings and configurations of your + instance. + </p> + </div> + + <div className="my-4"> + <div className="flex flex-col gap-y-2 mb-2.5"> + <label className="leading-tight font-medium text-black dark:text-white"> + Users can delete workspaces + </label> + <p className="leading-tight text-sm text-gray-500 dark:text-slate-400"> + allow non-admin users to delete workspaces that they are a + part of. This would delete the workspace for everyone. + </p> + </div> + <label className="relative inline-flex cursor-pointer items-center"> + <input + type="checkbox" + name="users_can_delete_workspaces" + checked={canDelete} + onChange={(e) => setCanDelete(e.target.checked)} + className="peer sr-only" + /> + <div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"></div> + <span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span> + </label> + </div> + + <div className="my-4"> + <div className="flex flex-col gap-y-2 mb-2.5"> + <label className="leading-tight font-medium text-black dark:text-white"> + Limit messages per user per day + </label> + <p className="leading-tight text-sm text-gray-500 dark:text-slate-400"> + 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. + </p> + </div> + <label className="relative inline-flex cursor-pointer items-center"> + <input + type="checkbox" + name="limit_user_messages" + value="yes" + checked={messageLimit.enabled} + onChange={(e) => { + setMessageLimit({ + ...messageLimit, + enabled: e.target.checked, + }); + }} + className="peer sr-only" + /> + <div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"></div> + <span className="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"></span> + </label> + </div> + {messageLimit.enabled && ( + <div className="mb-4"> + <label className=" block flex items-center gap-x-1 font-medium text-black dark:text-white"> + Message limit per day + </label> + <div className="relative"> + <input + type="number" + name="message_limit" + onScroll={(e) => 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" + /> + </div> + </div> + )} + </div> + </form> + </div> + </div> + ); +} 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 000000000..ac957d73b --- /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 ( + <dialog id={DIALOG_ID} className="bg-transparent outline-none"> + <div className="relative w-full max-w-2xl max-h-full"> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-xl font-semibold text-gray-900 dark:text-white"> + Add user to instance + </h3> + <button + onClick={hideModal} + type="button" + className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white" + data-modal-hide="staticModal" + > + <X className="text-gray-300 text-lg" /> + </button> + </div> + <form onSubmit={handleCreate}> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div> + <label + htmlFor="username" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Username + </label> + <input + name="username" + 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="User's username" + minLength={2} + required={true} + autoComplete="off" + /> + </div> + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Password + </label> + <input + 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="User's initial password" + required={true} + minLength={8} + autoComplete="off" + /> + </div> + <div> + <label + htmlFor="role" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Role + </label> + <select + name="role" + required={true} + defaultValue={"default"} + className="rounded-lg bg-gray-50 px-4 py-2 text-sm text-gray-800 outline-none dark:text-slate-200 dark:bg-stone-600" + > + <option value="default">Member</option> + <option value="admin">Administrator</option> + </select> + </div> + {error && ( + <p className="text-red-600 dark:text-red-400 text-sm"> + Error: {error} + </p> + )} + <p className="text-gray-800 dark:text-slate-200 text-xs md:text-sm"> + After creating a user they will need to login with their + initial login to get access. + </p> + </div> + </div> + <div className="flex w-full justify-between items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + <button + onClick={hideModal} + type="button" + className="text-gray-800 hover:bg-gray-100 px-4 py-1 rounded-lg dark:text-slate-200 dark:hover:bg-stone-900" + > + Cancel + </button> + <button + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-black dark:text-slate-200 dark:border-transparent dark:hover:text-slate-200 dark:hover:bg-gray-900 dark:focus:ring-gray-800" + > + Add user + </button> + </div> + </form> + </div> + </div> + </dialog> + ); +} 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 000000000..cdab66a67 --- /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 ( + <dialog id={EditUserModalId(user)} className="bg-transparent outline-none"> + <div className="relative w-[75vw] max-w-2xl max-h-full"> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-xl font-semibold text-gray-900 dark:text-white"> + Edit {user.username} + </h3> + <button + onClick={hideModal} + type="button" + className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white" + data-modal-hide="staticModal" + > + <X className="text-gray-300 text-lg" /> + </button> + </div> + <form onSubmit={handleUpdate}> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div> + <label + htmlFor="username" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Username + </label> + <input + name="username" + 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="User's username" + minLength={2} + defaultValue={user.username} + required={true} + autoComplete="off" + /> + </div> + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + New Password + </label> + <input + 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={`${user.username}'s new password`} + minLength={8} + autoComplete="off" + /> + </div> + <div> + <label + htmlFor="role" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Role + </label> + <select + name="role" + required={true} + defaultValue={user.role} + className="rounded-lg bg-gray-50 px-4 py-2 text-sm text-gray-800 outline-none dark:text-slate-200 dark:bg-stone-600" + > + <option value="default">Member</option> + <option value="admin">Administrator</option> + </select> + </div> + {error && ( + <p className="text-red-600 dark:text-red-400 text-sm"> + Error: {error} + </p> + )} + </div> + </div> + <div className="flex w-full justify-between items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + <button + onClick={hideModal} + type="button" + className="text-gray-800 hover:bg-gray-100 px-4 py-1 rounded-lg dark:text-slate-200 dark:hover:bg-stone-900" + > + Cancel + </button> + <button + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-black dark:text-slate-200 dark:border-transparent dark:hover:text-slate-200 dark:hover:bg-gray-900 dark:focus:ring-gray-800" + > + Update user + </button> + </div> + </form> + </div> + </div> + </dialog> + ); +} 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 000000000..df7fbc4ec --- /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 ( + <> + <tr ref={rowRef} className="bg-transparent"> + <th + scope="row" + className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white" + > + {user.username} + </th> + <td className="px-6 py-4">{titleCase(user.role)}</td> + <td className="px-6 py-4">{user.createdAt}</td> + <td className="px-6 py-4 flex items-center gap-x-6"> + <button + onClick={() => + document?.getElementById(EditUserModalId(user))?.showModal() + } + className="font-medium text-blue-600 dark:text-blue-300 px-2 py-1 rounded-lg hover:bg-blue-50 hover:dark:bg-blue-800 hover:dark:bg-opacity-20" + > + Edit + </button> + {currUser.id !== user.id && ( + <> + <button + onClick={handleSuspend} + className="font-medium text-orange-600 dark:text-orange-300 px-2 py-1 rounded-lg hover:bg-orange-50 hover:dark:bg-orange-800 hover:dark:bg-opacity-20" + > + {suspended ? "Unsuspend" : "Suspend"} + </button> + <button + onClick={handleDelete} + className="font-medium text-red-600 dark:text-red-300 px-2 py-1 rounded-lg hover:bg-red-50 hover:dark:bg-red-800 hover:dark:bg-opacity-20" + > + Delete + </button> + </> + )} + </td> + </tr> + <EditUserModal user={user} /> + </> + ); +} diff --git a/frontend/src/pages/Admin/Users/index.jsx b/frontend/src/pages/Admin/Users/index.jsx new file mode 100644 index 000000000..b7873bcbb --- /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 ( + <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> + {!isMobile && <Sidebar />} + <div + style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }} + className="transition-all duration-500 relative md:ml-[2px] md:mr-[8px] md:my-[16px] md:rounded-[26px] bg-white dark:bg-black-900 md:min-w-[82%] p-[18px] h-full overflow-y-scroll" + > + {isMobile && <SidebarMobileHeader />} + <div className="flex flex-col w-full px-1 md:px-8"> + <div className="w-full flex flex-col gap-y-1"> + <div className="items-center flex gap-x-4"> + <p className="text-3xl font-semibold text-slate-600 dark:text-slate-200"> + Instance users + </p> + <button + onClick={() => + document?.getElementById(NewUserModalId)?.showModal() + } + className="border border-slate-800 dark:border-slate-200 px-4 py-1 rounded-lg text-slate-800 dark:text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-800 hover:text-slate-100 dark:hover:bg-slate-200 dark:hover:text-slate-800" + > + <UserPlus className="h-4 w-4" /> Add user + </button> + </div> + <p className="text-sm font-base text-slate-600 dark:text-slate-200"> + These are all the accounts which have an account on this instance. + Removing an account will instantly remove their access to this + instance. + </p> + </div> + <UsersContainer /> + </div> + <NewUserModal /> + </div> + </div> + ); +} + +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 ( + <Skeleton.default + height="80vh" + width="100%" + baseColor={darkMode ? "#2a3a53" : null} + highlightColor={darkMode ? "#395073" : null} + count={1} + className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6" + containerClassName="flex w-full" + /> + ); + } + + return ( + <table className="md:w-3/4 w-full text-sm text-left text-gray-500 dark:text-gray-400 rounded-lg mt-5"> + <thead className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-stone-800 dark:text-gray-400"> + <tr> + <th scope="col" className="px-6 py-3 rounded-tl-lg"> + Username + </th> + <th scope="col" className="px-6 py-3"> + Role + </th> + <th scope="col" className="px-6 py-3"> + Created On + </th> + <th scope="col" className="px-6 py-3 rounded-tr-lg"> + Actions + </th> + </tr> + </thead> + <tbody> + {users.map((user) => ( + <UserRow key={user.id} currUser={currUser} user={user} /> + ))} + </tbody> + </table> + ); +} 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 000000000..bc38c1910 --- /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 ( + <dialog id={DIALOG_ID} className="bg-transparent outline-none"> + <div className="relative w-full max-w-2xl max-h-full"> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-xl font-semibold text-gray-900 dark:text-white"> + Add workspace to Instance + </h3> + <button + onClick={hideModal} + type="button" + className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white" + data-modal-hide="staticModal" + > + <X className="text-gray-300 text-lg" /> + </button> + </div> + <form onSubmit={handleCreate}> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div> + <label + htmlFor="name" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Workspace name + </label> + <input + name="name" + 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="My workspace" + minLength={4} + required={true} + autoComplete="off" + /> + </div> + {error && ( + <p className="text-red-600 dark:text-red-400 text-sm"> + Error: {error} + </p> + )} + <p className="text-gray-800 dark:text-slate-200 text-xs md:text-sm"> + After creating this workspace only admins will be able to see + it. You can add users after it has been created. + </p> + </div> + </div> + <div className="flex w-full justify-between items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + <button + onClick={hideModal} + type="button" + className="text-gray-800 hover:bg-gray-100 px-4 py-1 rounded-lg dark:text-slate-200 dark:hover:bg-stone-900" + > + Cancel + </button> + <button + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-black dark:text-slate-200 dark:border-transparent dark:hover:text-slate-200 dark:hover:bg-gray-900 dark:focus:ring-gray-800" + > + Create workspace + </button> + </div> + </form> + </div> + </div> + </dialog> + ); +} 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 000000000..c363dbed3 --- /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 ( + <dialog + id={EditWorkspaceUsersModalId(workspace)} + className="bg-transparent outline-none" + > + <div className="relative w-[75vw] max-w-2xl max-h-full"> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-xl font-semibold text-gray-900 dark:text-white"> + Edit {workspace.name} + </h3> + <button + onClick={hideModal} + type="button" + className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white" + data-modal-hide="staticModal" + > + <X className="text-gray-300 text-lg" /> + </button> + </div> + <form onSubmit={handleUpdate}> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + {users + .filter((user) => user.role !== "admin") + .map((user) => { + return ( + <div + key={`workspace-${workspace.id}-user-${user.id}`} + data-workspace={workspace.id} + className="flex items-center pl-4 border border-gray-200 rounded dark:border-gray-400 group hover:bg-stone-600 cursor-pointer" + onClick={() => { + document + .getElementById( + `workspace-${workspace.id}-user-${user.id}` + ) + ?.click(); + }} + > + <input + id={`workspace-${workspace.id}-user-${user.id}`} + defaultChecked={workspace.userIds.includes(user.id)} + type="checkbox" + value="yes" + name={`user-${user.id}`} + className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 pointer-events-none" + /> + <label + htmlFor={`user-${user.id}`} + className="pointer-events-none w-full py-4 ml-2 text-sm font-medium text-gray-900 dark:text-gray-300" + > + {titleCase(user.username)} + </label> + </div> + ); + })} + <div className="flex items-center gap-x-4"> + <button + type="button" + className="w-full p-4 flex dark:text-slate-200 text-gray-800 items-center pl-4 border border-gray-200 rounded dark:border-gray-400 group hover:bg-stone-600 cursor-pointer" + onClick={() => { + document + .getElementById(`workspace-${workspace.id}-select-all`) + ?.click(); + Array.from( + document.querySelectorAll( + `[data-workspace='${workspace.id}']` + ) + ).forEach((el) => { + if (!el.firstChild.checked) el.firstChild.click(); + }); + }} + > + Select All + </button> + <button + type="button" + className="w-full p-4 flex dark:text-slate-200 text-gray-800 items-center pl-4 border border-gray-200 rounded dark:border-gray-400 group hover:bg-stone-600 cursor-pointer" + onClick={() => { + document + .getElementById(`workspace-${workspace.id}-select-all`) + ?.click(); + Array.from( + document.querySelectorAll( + `[data-workspace='${workspace.id}']` + ) + ).forEach((el) => { + if (el.firstChild.checked) el.firstChild.click(); + }); + }} + > + Deselect All + </button> + </div> + {error && ( + <p className="text-red-600 dark:text-red-400 text-sm"> + Error: {error} + </p> + )} + </div> + </div> + <div className="flex w-full justify-between items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + <button + onClick={hideModal} + type="button" + className="text-gray-800 hover:bg-gray-100 px-4 py-1 rounded-lg dark:text-slate-200 dark:hover:bg-stone-900" + > + Cancel + </button> + <button + type="submit" + className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-black dark:text-slate-200 dark:border-transparent dark:hover:text-slate-200 dark:hover:bg-gray-900 dark:focus:ring-gray-800" + > + Update workspace + </button> + </div> + </form> + </div> + </div> + </dialog> + ); +} 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 000000000..762e2089b --- /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 ( + <> + <tr ref={rowRef} className="bg-transparent"> + <th + scope="row" + className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white" + > + {workspace.name} + </th> + <td className="px-6 py-4"> + <a + href={paths.workspace.chat(workspace.slug)} + target="_blank" + className="text-blue-500" + > + {workspace.slug} + </a> + </td> + <td className="px-6 py-4">{workspace.userIds?.length}</td> + <td className="px-6 py-4">{workspace.createdAt}</td> + <td className="px-6 py-4 flex items-center gap-x-6"> + <button + onClick={() => + document + ?.getElementById(EditWorkspaceUsersModalId(workspace)) + ?.showModal() + } + className="font-medium text-blue-600 dark:text-blue-300 px-2 py-1 rounded-lg hover:bg-blue-50 hover:dark:bg-blue-800 hover:dark:bg-opacity-20" + > + Edit Users + </button> + <button + onClick={handleDelete} + className="font-medium text-red-600 dark:text-red-300 px-2 py-1 rounded-lg hover:bg-red-50 hover:dark:bg-red-800 hover:dark:bg-opacity-20" + > + Delete + </button> + </td> + </tr> + <EditWorkspaceUsersModal workspace={workspace} users={users} /> + </> + ); +} diff --git a/frontend/src/pages/Admin/Workspaces/index.jsx b/frontend/src/pages/Admin/Workspaces/index.jsx new file mode 100644 index 000000000..ff3cb45a2 --- /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 ( + <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> + {!isMobile && <Sidebar />} + <div + style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }} + className="transition-all duration-500 relative md:ml-[2px] md:mr-[8px] md:my-[16px] md:rounded-[26px] bg-white dark:bg-black-900 md:min-w-[82%] p-[18px] h-full overflow-y-scroll" + > + {isMobile && <SidebarMobileHeader />} + <div className="flex flex-col w-full px-1 md:px-8"> + <div className="w-full flex flex-col gap-y-1"> + <div className="items-center flex gap-x-4"> + <p className="text-3xl font-semibold text-slate-600 dark:text-slate-200"> + Instance workspaces + </p> + <button + onClick={() => + document?.getElementById(NewWorkspaceModalId)?.showModal() + } + className="border border-slate-800 dark:border-slate-200 px-4 py-1 rounded-lg text-slate-800 dark:text-slate-200 text-sm items-center flex gap-x-2 hover:bg-slate-800 hover:text-slate-100 dark:hover:bg-slate-200 dark:hover:text-slate-800" + > + <BookOpen className="h-4 w-4" /> New Workspace + </button> + </div> + <p className="text-sm font-base text-slate-600 dark:text-slate-200"> + These are all the workspaces that exist on this instance. Removing + a workspace will delete all of it's associated chats and settings. + </p> + </div> + <WorkspacesContainer /> + </div> + <NewWorkspaceModal /> + </div> + </div> + ); +} + +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 ( + <Skeleton.default + height="80vh" + width="100%" + baseColor={darkMode ? "#2a3a53" : null} + highlightColor={darkMode ? "#395073" : null} + count={1} + className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6" + containerClassName="flex w-full" + /> + ); + } + + return ( + <table className="md:w-3/4 w-full text-sm text-left text-gray-500 dark:text-gray-400 rounded-lg mt-5"> + <thead className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-stone-800 dark:text-gray-400"> + <tr> + <th scope="col" className="px-6 py-3 rounded-tl-lg"> + Name + </th> + <th scope="col" className="px-6 py-3"> + Link + </th> + <th scope="col" className="px-6 py-3"> + Users + </th> + <th scope="col" className="px-6 py-3"> + Created On + </th> + <th scope="col" className="px-6 py-3 rounded-tr-lg"> + Actions + </th> + </tr> + </thead> + <tbody> + {workspaces.map((workspace) => ( + <WorkspaceRow + key={workspace.id} + workspace={workspace} + users={users} + /> + ))} + </tbody> + </table> + ); +} diff --git a/frontend/src/pages/Invite/NewUserModal/index.jsx b/frontend/src/pages/Invite/NewUserModal/index.jsx new file mode 100644 index 000000000..ae3fb6ed3 --- /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 ( + <dialog open={true} className="bg-transparent outline-none"> + <div className="relative w-full max-w-2xl max-h-full"> + <div className="relative bg-white rounded-lg shadow dark:bg-stone-700"> + <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"> + <h3 className="text-xl font-semibold text-gray-900 dark:text-white"> + Create a new account + </h3> + </div> + <form onSubmit={handleCreate}> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div> + <label + htmlFor="username" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Username + </label> + <input + name="username" + 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="My username" + minLength={2} + required={true} + autoComplete="off" + /> + </div> + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-gray-900 dark:text-white" + > + Password + </label> + <input + 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 password" + required={true} + minLength={8} + autoComplete="off" + /> + </div> + {error && ( + <p className="text-red-600 dark:text-red-400 text-sm"> + Error: {error} + </p> + )} + <p className="text-gray-800 dark:text-slate-200 text-xs md:text-sm"> + After creating your account you will be able to login with + these credentials and start using workspaces. + </p> + </div> + </div> + <div className="flex w-full justify-between items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600"> + <button + type="submit" + className="w-full text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-black dark:text-slate-200 dark:border-transparent dark:hover:text-slate-200 dark:hover:bg-gray-900 dark:focus:ring-gray-800" + > + Accept Invitation + </button> + </div> + </form> + </div> + </div> + </dialog> + ); +} diff --git a/frontend/src/pages/Invite/index.jsx b/frontend/src/pages/Invite/index.jsx new file mode 100644 index 000000000..2818fd11f --- /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 ( + <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> + <FullScreenLoader /> + </div> + ); + } + + if (result.status === "invalid") { + return ( + <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex items-center justify-center"> + <p className="text-red-600 text-lg">{result.message}</p> + </div> + ); + } + + return ( + <div className="w-screen h-screen overflow-hidden bg-gray-100 dark:bg-stone-900 flex items-center justify-center"> + <NewUserModal /> + </div> + ); +} diff --git a/frontend/src/pages/Main/index.jsx b/frontend/src/pages/Main/index.jsx index 29b5ee3e7..d65881812 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 && <PasswordModal />} + {requiresAuth && <PasswordModal mode={mode} />} <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> {!isMobile && <SidebarPlaceholder />} <ChatPlaceholder /> diff --git a/frontend/src/pages/WorkspaceChat/index.jsx b/frontend/src/pages/WorkspaceChat/index.jsx index 1eec90412..612b51a0b 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 && <PasswordModal />} + {requiresAuth && <PasswordModal mode={mode} />} <div className="w-screen h-screen overflow-hidden bg-orange-100 dark:bg-stone-700 flex"> {!isMobile && <SidebarPlaceholder />} <ChatPlaceholder /> diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 53995e6ad..e96e68363 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 03ff7b54f..57eee6e1e 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 4de7681e9..271c97b20 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 000000000..27228e482 --- /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 7df9953fb..12d46fe3e 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 a74cec570..f671f78df 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 000000000..b58dc0c15 --- /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 7c3036434..0a9544d5d 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 000000000..3f3b14523 --- /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 a39ef3a3b..e78af6add 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 3473c6d32..f103a1c59 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 83ff994da..0164a03e2 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 777bd7175..0de83bcd5 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 000000000..0a945c4fa --- /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 000000000..1df81204f --- /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 000000000..9bfee3509 --- /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 9e1a8dd42..e568097be 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 fed92434b..5049a2bae 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 7a2aafb85..027448bff 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 000000000..8dacbac11 --- /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 c9cc4d203..24b84210a 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 59f9448e7..8851efdf1 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 800003e21..5e099dad0 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 0b1f42bdc..e3b658a54 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 9fd643b75..3543a36b3 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 4e7c519a8..1ff13f3d0 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 863b6f130..801a41db8 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 3315028a7..ddc184693 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 0c03e75bb..e34391b19 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 d5cef6394..1a82497ed 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"