[FEATURE] Enable the ability to have multi user instances (#158)

* multi user wip

* WIP MUM features

* invitation mgmt

* suspend or unsuspend users

* workspace mangement

* manage chats

* manage chats

* add Support for admin system settings for users to delete workspaces and limit chats per user

* fix issue ith system var
update app to lazy load invite page

* cleanup and bug fixes

* wrong method

* update readme

* update readme

* update readme

* bump version to 0.1.0
This commit is contained in:
Timothy Carambat 2023-07-25 10:37:04 -07:00 committed by GitHub
parent 2d30b7e24f
commit 91f5f94200
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 4834 additions and 604 deletions

View File

@ -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>
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/tim.svg?style=social&label=Follow%20%40Timothy%20Carambat)](https://twitter.com/tcarambat) [![](https://dcbadge.vercel.app/api/server/6UyHPeGZAC?compact=true&style=flat)](https://discord.gg/6UyHPeGZAC)
<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.
![Chatting](/images/screenshots/chat.png)
@ -14,20 +30,21 @@ A full-stack application and tool suite that enables you to turn any document, r
### Product Overview
AnythingLLM aims to be a full-stack application where you can use commercial off-the-shelf LLMs with Long-term-memory solutions or use popular open source LLM and vectorDB solutions.
AnythingLLM aims to be a full-stack application where you can use commercial off-the-shelf LLMs or popular open source LLMs and vectorDB solutions.
Anything LLM is a full-stack product that you can run locally as well as host remotely and be able to chat intelligently with any documents you provide it.
AnythingLLM divides your documents into objects called `workspaces`. A Workspace functions a lot like a thread, but with the addition of containerization of your documents. Workspaces can share documents, but they do not talk to each other so you can keep your context for each workspace clean.
Some cool features of AnythingLLM
- Atomically manage documents to be used in long-term-memory from a simple UI
- Multi-user instance support and oversight
- Atomically manage documents in your vector database from a simple UI
- Two chat modes `conversation` and `query`. Conversation retains previous questions and amendments. Query is simple QA against your documents
- Each chat response contains a citation that is linked to the original content
- Simple technology stack for fast iteration
- Fully capable of being hosted remotely
- "Bring your own LLM" model and vector solution. _still in progress_
- Extremely efficient cost-saving measures for managing very large documents. you'll never pay to embed a massive document or transcript more than once. 90% more cost effective than other LTM chatbots
- 100% Cloud deployment ready.
- "Bring your own LLM" model. _still in progress - openai support only currently_
- Extremely efficient cost-saving measures for managing very large documents. You'll never pay to embed a massive document or transcript more than once. 90% more cost effective than other document chatbot solutions.
### Technical Overview
This monorepo consists of three main sections:
@ -37,8 +54,8 @@ This monorepo consists of three main sections:
### Requirements
- `yarn` and `node` on your machine
- `python` 3.8+ for running scripts in `collector/`.
- access to an LLM like `GPT-3.5`, `GPT-4`*.
- `python` 3.9+ for running scripts in `collector/`.
- access to an LLM like `GPT-3.5`, `GPT-4`.
- a [Pinecone.io](https://pinecone.io) free account*.
*you can use drop in replacements for these. This is just the easiest to get up and running fast. We support multiple vector database providers.

View File

@ -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"
}
}
}

View File

@ -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>

View File

@ -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 });
},
});

View File

@ -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>
);
};

View File

@ -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 };
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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

View File

@ -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 = () => {

View File

@ -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>
);
}

View File

@ -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()} />;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
export default function useQuery() {
return new URLSearchParams(window.location.search);
}

View File

@ -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 };
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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",

View File

@ -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(() => []);

View File

@ -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>
);
};

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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} />
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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} />
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 />

View File

@ -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 />

View File

@ -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";

View File

@ -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";
},
},
};

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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",

View File

@ -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"

348
server/endpoints/admin.js Normal file
View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -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",

View File

@ -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 };

View File

@ -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"] })

View File

@ -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));

191
server/models/invite.js Normal file
View File

@ -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;

View File

@ -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;

170
server/models/user.js Normal file
View File

@ -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 };

View File

@ -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));

View File

@ -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 };

View File

@ -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 };

View File

@ -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;

View File

@ -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": {

View File

@ -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",

View File

@ -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,

View File

@ -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);
}

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,

View File

@ -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",

View File

@ -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: [],

View File

@ -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"