Create manager role and limit default role (#351)

* added manager role to options

* block default role from editing workspace settings on workspace and text input box

* block default user from accessing settings at all

* create manager route

* let pass through if in single user mode

* fix permissions for manager and admin roles in settings

* fix settings button for single user and remove unneeded console.logs

* rename routes and paths for clarity

* admin, manager, default roles complete

* remove unneeded comments

* consistency changes

* manage permissions for mum modes

* update sidebar for single-user mode

* update comment on middleware
Modify permission setting for admins

* update render conditional

* Add role usage hint to each role

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2023-11-13 14:51:16 -08:00 committed by GitHub
parent 7fcf29d769
commit fa29003a46
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 462 additions and 456 deletions

View File

@ -1,7 +1,10 @@
import React, { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { ContextWrapper } from "./AuthContext";
import PrivateRoute, { AdminRoute } from "./components/PrivateRoute";
import PrivateRoute, {
AdminRoute,
ManagerRoute,
} from "./components/PrivateRoute";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import Login from "./pages/Login";
@ -48,56 +51,55 @@ export default function App() {
/>
<Route path="/accept-invite/:code" element={<InvitePage />} />
{/* General Routes */}
{/* Admin */}
<Route
path="/general/llm-preference"
element={<PrivateRoute Component={GeneralLLMPreference} />}
path="/settings/llm-preference"
element={<AdminRoute Component={GeneralLLMPreference} />}
/>
<Route
path="/general/embedding-preference"
element={<PrivateRoute Component={GeneralEmbeddingPreference} />}
path="/settings/embedding-preference"
element={<AdminRoute Component={GeneralEmbeddingPreference} />}
/>
<Route
path="/general/vector-database"
element={<PrivateRoute Component={GeneralVectorDatabase} />}
path="/settings/vector-database"
element={<AdminRoute Component={GeneralVectorDatabase} />}
/>
{/* Manager */}
<Route
path="/settings/export-import"
element={<ManagerRoute Component={GeneralExportImport} />}
/>
<Route
path="/general/export-import"
element={<PrivateRoute Component={GeneralExportImport} />}
path="/settings/security"
element={<ManagerRoute Component={GeneralSecurity} />}
/>
<Route
path="/general/security"
element={<PrivateRoute Component={GeneralSecurity} />}
path="/settings/appearance"
element={<ManagerRoute Component={GeneralAppearance} />}
/>
<Route
path="/general/appearance"
element={<PrivateRoute Component={GeneralAppearance} />}
path="/settings/api-keys"
element={<ManagerRoute Component={GeneralApiKeys} />}
/>
<Route
path="/general/api-keys"
element={<PrivateRoute Component={GeneralApiKeys} />}
path="/settings/workspace-chats"
element={<ManagerRoute Component={GeneralChats} />}
/>
<Route
path="/general/workspace-chats"
element={<PrivateRoute Component={GeneralChats} />}
/>
{/* Admin Routes */}
<Route
path="/admin/system-preferences"
element={<AdminRoute Component={AdminSystem} />}
path="/settings/system-preferences"
element={<ManagerRoute Component={AdminSystem} />}
/>
<Route
path="/admin/invites"
element={<AdminRoute Component={AdminInvites} />}
path="/settings/invites"
element={<ManagerRoute Component={AdminInvites} />}
/>
<Route
path="/admin/users"
element={<AdminRoute Component={AdminUsers} />}
path="/settings/users"
element={<ManagerRoute Component={AdminUsers} />}
/>
<Route
path="/admin/workspaces"
element={<AdminRoute Component={AdminWorkspaces} />}
path="/settings/workspaces"
element={<ManagerRoute Component={AdminWorkspaces} />}
/>
{/* Onboarding Flow */}
<Route path="/onboarding" element={<OnboardingFlow />} />

View File

@ -14,7 +14,7 @@ export default function AnthropicAiOptions({ settings, showAlert = false }) {
</p>
</div>
<a
href={paths.general.embeddingPreference()}
href={paths.settings.embeddingPreference()}
className="text-sm md:text-base my-2 underline"
>
Manage embedding &rarr;

View File

@ -14,7 +14,7 @@ export default function LMStudioOptions({ settings, showAlert = false }) {
</p>
</div>
<a
href={paths.general.embeddingPreference()}
href={paths.settings.embeddingPreference()}
className="text-sm md:text-base my-2 underline"
>
Manage embedding &rarr;

View File

@ -4,6 +4,7 @@ import { useParams } from "react-router-dom";
import Workspace from "../../../models/workspace";
import System from "../../../models/system";
import { isMobile } from "react-device-detect";
import useUser from "../../../hooks/useUser";
const DocumentSettings = lazy(() => import("./Documents"));
const WorkspaceSettings = lazy(() => import("./Settings"));
@ -117,9 +118,13 @@ const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => {
export default memo(ManageWorkspace);
export function useManageWorkspaceModal() {
const { user } = useUser();
const [showing, setShowing] = useState(false);
const showModal = () => {
setShowing(true);
if (user?.role !== "default") {
setShowing(true);
}
};
const hideModal = () => {

View File

@ -14,6 +14,7 @@ function useIsAuthenticated() {
const [isAuthd, setIsAuthed] = useState(null);
const [shouldRedirectToOnboarding, setShouldRedirectToOnboarding] =
useState(false);
const [multiUserMode, setMultiUserMode] = useState(false);
useEffect(() => {
const validateSession = async () => {
@ -25,6 +26,8 @@ function useIsAuthenticated() {
AzureOpenAiKey = false,
} = await System.keys();
setMultiUserMode(MultiUserMode);
// Check for the onboarding redirect condition
if (
!MultiUserMode &&
@ -77,11 +80,14 @@ function useIsAuthenticated() {
validateSession();
}, []);
return { isAuthd, shouldRedirectToOnboarding };
return { isAuthd, shouldRedirectToOnboarding, multiUserMode };
}
// Allows only admin to access the route and if in single user mode,
// allows all users to access the route
export function AdminRoute({ Component }) {
const { isAuthd, shouldRedirectToOnboarding } = useIsAuthenticated();
const { isAuthd, shouldRedirectToOnboarding, multiUserMode } =
useIsAuthenticated();
if (isAuthd === null) return <FullScreenLoader />;
if (shouldRedirectToOnboarding) {
@ -89,7 +95,28 @@ export function AdminRoute({ Component }) {
}
const user = userFromStorage();
return isAuthd && user?.role === "admin" ? (
return isAuthd && (user?.role === "admin" || !multiUserMode) ? (
<UserMenu>
<Component />
</UserMenu>
) : (
<Navigate to={paths.home()} />
);
}
// Allows manager and admin to access the route and if in single user mode,
// allows all users to access the route
export function ManagerRoute({ Component }) {
const { isAuthd, shouldRedirectToOnboarding, multiUserMode } =
useIsAuthenticated();
if (isAuthd === null) return <FullScreenLoader />;
if (shouldRedirectToOnboarding) {
return <Navigate to={paths.onboarding()} />;
}
const user = userFromStorage();
return isAuthd && (user?.role !== "default" || !multiUserMode) ? (
<UserMenu>
<Component />
</UserMenu>

View File

@ -65,96 +65,84 @@ export default function SettingsSidebar() {
<div className="h-[100%] flex flex-col w-full justify-between pt-4 overflow-y-hidden">
<div className="h-auto sidebar-items">
<div className="flex flex-col gap-y-2 h-[65vh] pb-8 overflow-y-scroll no-scroll">
{/* Admin Settings */}
{user?.role === "admin" && (
{/* Admin/manager Multi-user Settings */}
{!!user && user?.role !== "default" && (
<>
<Option
href={paths.admin.system()}
href={paths.settings.system()}
btnText="System Preferences"
icon={<SquaresFour className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.admin.invites()}
href={paths.settings.invites()}
btnText="Invitation"
icon={
<EnvelopeSimple className="h-5 w-5 flex-shrink-0" />
}
/>
<Option
href={paths.admin.users()}
href={paths.settings.users()}
btnText="Users"
icon={<Users className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.admin.workspaces()}
href={paths.settings.workspaces()}
btnText="Workspaces"
icon={<BookOpen className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.chats()}
btnText="Workspace Chat"
icon={
<ChatCenteredText className="h-5 w-5 flex-shrink-0" />
}
/>
</>
)}
{/* General Settings */}
<Option
href={paths.general.appearance()}
href={paths.settings.chats()}
btnText="Workspace Chat"
icon={<ChatCenteredText className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.appearance()}
btnText="Appearance"
icon={<Eye className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.apiKeys()}
href={paths.settings.apiKeys()}
btnText="API Keys"
icon={<Key className="h-5 w-5 flex-shrink-0" />}
/>
{(!user || user?.role === "admin") && (
<>
<Option
href={paths.settings.llmPreference()}
btnText="LLM Preference"
icon={<ChatText className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.embeddingPreference()}
btnText="Embedding Preference"
icon={<FileCode className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.vectorDatabase()}
btnText="Vector Database"
icon={<Database className="h-5 w-5 flex-shrink-0" />}
/>
</>
)}
<Option
href={paths.general.llmPreference()}
btnText="LLM Preference"
icon={<ChatText className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.embeddingPreference()}
btnText="Embedding Preference"
icon={<FileCode className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.vectorDatabase()}
btnText="Vector Database"
icon={<Database className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.exportImport()}
href={paths.settings.exportImport()}
btnText="Export or Import"
icon={<DownloadSimple className="h-5 w-5 flex-shrink-0" />}
/>
{!user && (
<Option
href={paths.general.chats()}
btnText="Chat History"
icon={
<ChatCenteredText className="h-5 w-5 flex-shrink-0" />
}
/>
)}
<Option
href={paths.general.security()}
href={paths.settings.security()}
btnText="Security"
icon={<Lock className="h-5 w-5 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 justify-center mt-2">
<div className="flex space-x-4">
@ -277,73 +265,70 @@ export function SidebarMobileHeader() {
style={{ height: "calc(100vw - -3rem)" }}
className=" flex flex-col gap-y-4 pb-8 overflow-y-scroll no-scroll"
>
{user?.role === "admin" && (
<>
<Option
href={paths.admin.system()}
btnText="System Preferences"
icon={<SquaresFour className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.admin.invites()}
btnText="Invitation"
icon={
<EnvelopeSimple className="h-5 w-5 flex-shrink-0" />
}
/>
<Option
href={paths.admin.users()}
btnText="Users"
icon={<Users className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.admin.workspaces()}
btnText="Workspaces"
icon={<BookOpen className="h-5 w-5 flex-shrink-0" />}
/>
</>
)}
{/* General Settings */}
<Option
href={paths.general.chats()}
href={paths.settings.system()}
btnText="System Preferences"
icon={<SquaresFour className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.invites()}
btnText="Invitation"
icon={<EnvelopeSimple className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.users()}
btnText="Users"
icon={<Users className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.workspaces()}
btnText="Workspaces"
icon={<BookOpen className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.chats()}
btnText="Workspace Chat"
icon={
<ChatCenteredText className="h-5 w-5 flex-shrink-0" />
}
/>
<Option
href={paths.general.appearance()}
href={paths.settings.appearance()}
btnText="Appearance"
icon={<Eye className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.apiKeys()}
href={paths.settings.apiKeys()}
btnText="API Keys"
icon={<Key className="h-5 w-5 flex-shrink-0" />}
/>
{(!user || user?.role === "admin") && (
<>
<Option
href={paths.settings.llmPreference()}
btnText="LLM Preference"
icon={<ChatText className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.embeddingPreference()}
btnText="Embedding Preference"
icon={<FileCode className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.settings.vectorDatabase()}
btnText="Vector Database"
icon={<Database className="h-5 w-5 flex-shrink-0" />}
/>
</>
)}
<Option
href={paths.general.llmPreference()}
btnText="LLM Preference"
icon={<ChatText className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.embeddingPreference()}
btnText="Embedding Preference"
icon={<FileCode className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.vectorDatabase()}
btnText="Vector Database"
icon={<Database className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.exportImport()}
href={paths.settings.exportImport()}
btnText="Export or Import"
icon={<DownloadSimple className="h-5 w-5 flex-shrink-0" />}
/>
<Option
href={paths.general.security()}
href={paths.settings.security()}
btnText="Security"
icon={<Lock className="h-5 w-5 flex-shrink-0" />}
/>

View File

@ -9,6 +9,7 @@ import paths from "../../../utils/paths";
import { useParams } from "react-router-dom";
import { GearSix, SquaresFour } from "@phosphor-icons/react";
import truncate from "truncate";
import useUser from "../../../hooks/useUser";
export default function ActiveWorkspaces() {
const { slug } = useParams();
@ -17,6 +18,7 @@ export default function ActiveWorkspaces() {
const [workspaces, setWorkspaces] = useState([]);
const [selectedWs, setSelectedWs] = useState(null);
const { showing, showModal, hideModal } = useManageWorkspaceModal();
const { user } = useUser();
useEffect(() => {
async function getWorkspaces() {
@ -90,7 +92,7 @@ export default function ActiveWorkspaces() {
>
<GearSix
weight={settingHover ? "fill" : "regular"}
hidden={!isActive}
hidden={!isActive || user?.role === "default"}
className="h-[20px] w-[20px] transition-all duration-300"
/>
</button>

View File

@ -15,8 +15,10 @@ import ActiveWorkspaces from "./ActiveWorkspaces";
import paths from "../../utils/paths";
import { USER_BACKGROUND_COLOR } from "../../utils/constants";
import useLogo from "../../hooks/useLogo";
import useUser from "../../hooks/useUser";
export default function Sidebar() {
const { user } = useUser();
const { logo } = useLogo();
const sidebarRef = useRef(null);
const {
@ -43,25 +45,28 @@ export default function Sidebar() {
style={{ objectFit: "contain" }}
/>
</div>
<div className="flex gap-x-2 items-center text-slate-200">
{/* <AdminHome /> */}
<SettingsButton />
</div>
{(!user || user?.role !== "default") && (
<div className="flex gap-x-2 items-center text-slate-200">
<SettingsButton />
</div>
)}
</div>
{/* Primary Body */}
<div className="flex-grow flex flex-col">
<div className="flex flex-col gap-y-4 pb-8 overflow-y-scroll no-scroll">
<div className="flex gap-x-2 items-center justify-between">
<button
onClick={showNewWsModal}
className="flex flex-grow w-[75%] h-[44px] gap-x-2 py-[5px] px-4 bg-white rounded-lg text-sidebar justify-center items-center hover:bg-opacity-80 transition-all duration-300"
>
<Plus className="h-5 w-5" />
<p className="text-sidebar text-sm font-semibold">
New Workspace
</p>
</button>
{(!user || user?.role !== "default") && (
<button
onClick={showNewWsModal}
className="flex flex-grow w-[75%] h-[44px] gap-x-2 py-[5px] px-4 bg-white rounded-lg text-sidebar justify-center items-center hover:bg-opacity-80 transition-all duration-300"
>
<Plus className="h-5 w-5" />
<p className="text-sidebar text-sm font-semibold">
New Workspace
</p>
</button>
)}
</div>
<ActiveWorkspaces />
</div>
@ -133,6 +138,7 @@ export function SidebarMobileHeader() {
showModal: showNewWsModal,
hideModal: hideNewWsModal,
} = useNewWorkspaceModal();
const { user } = useUser();
useEffect(() => {
// Darkens the rest of the screen
@ -197,9 +203,11 @@ export function SidebarMobileHeader() {
style={{ objectFit: "contain" }}
/>
</div>
<div className="flex gap-x-2 items-center text-slate-500 shink-0">
<SettingsButton />
</div>
{(!user || user?.role !== "default") && (
<div className="flex gap-x-2 items-center text-slate-500 shink-0">
<SettingsButton />
</div>
)}
</div>
{/* Primary Body */}
@ -210,15 +218,17 @@ export function SidebarMobileHeader() {
className=" flex flex-col gap-y-4 pb-8 overflow-y-scroll no-scroll"
>
<div className="flex gap-x-2 items-center justify-between">
<button
onClick={showNewWsModal}
className="flex flex-grow w-[75%] h-[44px] gap-x-2 py-[5px] px-4 bg-white rounded-lg text-sidebar justify-center items-center hover:bg-opacity-80 transition-all duration-300"
>
<Plus className="h-5 w-5" />
<p className="text-sidebar text-sm font-semibold">
New Workspace
</p>
</button>
{(!user || user?.role !== "default") && (
<button
onClick={showNewWsModal}
className="flex flex-grow w-[75%] h-[44px] gap-x-2 py-[5px] px-4 bg-white rounded-lg text-sidebar justify-center items-center hover:bg-opacity-80 transition-all duration-300"
>
<Plus className="h-5 w-5" />
<p className="text-sidebar text-sm font-semibold">
New Workspace
</p>
</button>
)}
</div>
<ActiveWorkspaces />
</div>
@ -266,7 +276,7 @@ export function SidebarMobileHeader() {
function SettingsButton() {
return (
<a
href={paths.general.llmPreference()}
href={paths.settings.system()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<Wrench className="h-4 w-4" weight="fill" />

View File

@ -10,6 +10,7 @@ import { isMobile } from "react-device-detect";
import ManageWorkspace, {
useManageWorkspaceModal,
} from "../../../Modals/MangeWorkspace";
import useUser from "../../../../hooks/useUser";
export default function PromptInput({
workspace,
@ -22,6 +23,7 @@ export default function PromptInput({
const { showing, showModal, hideModal } = useManageWorkspaceModal();
const formRef = useRef(null);
const [_, setFocused] = useState(false);
const { user } = useUser();
const handleSubmit = (e) => {
setFocused(false);
@ -86,11 +88,14 @@ export default function PromptInput({
</div>
<div className="flex justify-between py-3.5">
<div className="flex gap-2">
<Gear
onClick={showModal}
className="w-7 h-7 text-white/60 hover:text-white cursor-pointer"
weight="fill"
/>
{user?.role !== "default" && (
<Gear
onClick={showModal}
className="w-7 h-7 text-white/60 hover:text-white cursor-pointer"
weight="fill"
/>
)}
<ChatModeSelector workspace={workspace} />
{/* <TextT
className="w-7 h-7 text-white/30 cursor-not-allowed"

View File

@ -1,6 +1,8 @@
import React, { useState } from "react";
import { X } from "@phosphor-icons/react";
import Admin from "../../../../models/admin";
import { userFromStorage } from "../../../../utils/request";
import { RoleHintDisplay } from "..";
const DIALOG_ID = `new-user-modal`;
@ -11,6 +13,7 @@ function hideModal() {
export const NewUserModalId = DIALOG_ID;
export default function NewUserModal() {
const [error, setError] = useState(null);
const [role, setRole] = useState("default");
const handleCreate = async (e) => {
setError(null);
e.preventDefault();
@ -22,6 +25,8 @@ export default function NewUserModal() {
setError(error);
};
const user = userFromStorage();
return (
<dialog id={DIALOG_ID} className="bg-transparent outline-none">
<div className="relative w-full max-w-2xl max-h-full">
@ -87,11 +92,16 @@ export default function NewUserModal() {
name="role"
required={true}
defaultValue={"default"}
onChange={(e) => setRole(e.target.value)}
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white border border-gray-500 focus:ring-blue-500 focus:border-blue-500"
>
<option value="default">Default</option>
<option value="admin">Administrator</option>
<option value="manager">Manager </option>
{user?.role === "admin" && (
<option value="admin">Administrator</option>
)}
</select>
<RoleHintDisplay role={role} />
</div>
{error && (
<p className="text-red-400 text-sm">Error: {error}</p>

View File

@ -1,10 +1,12 @@
import React, { useState } from "react";
import { X } from "@phosphor-icons/react";
import Admin from "../../../../../models/admin";
import { RoleHintDisplay } from "../..";
export const EditUserModalId = (user) => `edit-user-${user.id}-modal`;
export default function EditUserModal({ user }) {
export default function EditUserModal({ currentUser, user }) {
const [role, setRole] = useState(user.role);
const [error, setError] = useState(null);
const hideModal = () => {
@ -90,11 +92,16 @@ export default function EditUserModal({ user }) {
name="role"
required={true}
defaultValue={user.role}
onChange={(e) => setRole(e.target.value)}
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm text-white border border-gray-500 focus:ring-blue-500 focus:border-blue-500"
>
<option value="default">Default</option>
<option value="admin">Administrator</option>
<option value="manager">Manager</option>
{currentUser?.role === "admin" && (
<option value="admin">Administrator</option>
)}
</select>
<RoleHintDisplay role={role} />
</div>
{error && (
<p className="text-red-400 text-sm">Error: {error}</p>

View File

@ -40,15 +40,17 @@ export default function UserRow({ currUser, user }) {
<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-white text-opacity-80 rounded-lg hover:text-white px-2 py-1 hover:text-opacity-60 hover:bg-white hover:bg-opacity-10"
>
<DotsThreeOutline weight="fill" className="h-5 w-5" />
</button>
{currUser.id !== user.id && (
{currUser?.role !== "default" && (
<button
onClick={() =>
document?.getElementById(EditUserModalId(user))?.showModal()
}
className="font-medium text-white text-opacity-80 rounded-lg hover:text-white px-2 py-1 hover:text-opacity-60 hover:bg-white hover:bg-opacity-10"
>
<DotsThreeOutline weight="fill" className="h-5 w-5" />
</button>
)}
{currUser?.id !== user.id && currUser?.role !== "default" && (
<>
<button
onClick={handleSuspend}
@ -66,7 +68,7 @@ export default function UserRow({ currUser, user }) {
)}
</td>
</tr>
<EditUserModal user={user} />
<EditUserModal currentUser={currUser} user={user} />
</>
);
}

View File

@ -100,3 +100,35 @@ function UsersContainer() {
</table>
);
}
const ROLE_HINT = {
default: [
"Can only send chats with workspaces they are added to by admin or managers.",
"Cannot modify any settings at all.",
],
manager: [
"Can view all workspaces and modify all settings.",
"Cannot modify LLM, vectorDB, embedding, or other connections.",
],
admin: [
"Highest user level privilege.",
"Can see and do everything across the system.",
],
};
export function RoleHintDisplay({ role }) {
return (
<div className="flex flex-col gap-y-1 py-1 pb-4">
<p className="text-white/60 font-semibold text-sm">Permissions</p>
<ul className="flex flex-col gap-y-1 list-disc px-4">
{ROLE_HINT[role ?? "default"].map((hints, i) => {
return (
<li key={i} className="text-xs text-white/60">
{hints}
</li>
);
})}
</ul>
</div>
);
}

View File

@ -55,7 +55,7 @@ function MultiUserMode() {
window.localStorage.removeItem(AUTH_USER);
window.localStorage.removeItem(AUTH_TOKEN);
window.localStorage.removeItem(AUTH_TIMESTAMP);
window.location = paths.admin.users();
window.location = paths.settings.users();
}, 2_000);
return;
}

View File

@ -39,47 +39,42 @@ export default {
apiDocs: () => {
return `${API_BASE}/docs`;
},
general: {
llmPreference: () => {
return "/general/llm-preference";
},
embeddingPreference: () => {
return "/general/embedding-preference";
},
vectorDatabase: () => {
return "/general/vector-database";
},
exportImport: () => {
return "/general/export-import";
},
security: () => {
return "/general/security";
},
appearance: () => {
return "/general/appearance";
},
apiKeys: () => {
return "/general/api-keys";
},
chats: () => {
return "/general/workspace-chats";
},
},
admin: {
settings: {
system: () => {
return `/admin/system-preferences`;
return `/settings/system-preferences`;
},
users: () => {
return `/admin/users`;
return `/settings/users`;
},
invites: () => {
return `/admin/invites`;
return `/settings/invites`;
},
workspaces: () => {
return `/admin/workspaces`;
return `/settings/workspaces`;
},
chats: () => {
return "/admin/workspace-chats";
return "/settings/workspace-chats";
},
llmPreference: () => {
return "/settings/llm-preference";
},
embeddingPreference: () => {
return "/settings/embedding-preference";
},
vectorDatabase: () => {
return "/settings/vector-database";
},
exportImport: () => {
return "/settings/export-import";
},
security: () => {
return "/settings/security";
},
appearance: () => {
return "/settings/appearance";
},
apiKeys: () => {
return "/settings/api-keys";
},
},
};

View File

@ -7,41 +7,37 @@ 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 { reqBody, userFromSession } = require("../utils/http");
const {
strictMultiUserRoleValid,
} = require("../utils/middleware/multiUserProtected");
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;
app.get(
"/admin/users",
[validatedRequest, strictMultiUserRoleValid],
async (_request, response) => {
try {
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();
}
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],
[validatedRequest, strictMultiUserRoleValid],
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 });
@ -52,34 +48,27 @@ function adminEndpoints(app) {
}
);
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;
app.post(
"/admin/user/:id",
[validatedRequest, strictMultiUserRoleValid],
async (request, response) => {
try {
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();
}
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],
[validatedRequest, strictMultiUserRoleValid],
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: Number(id) });
response.status(200).json({ success: true, error: null });
@ -90,33 +79,26 @@ function adminEndpoints(app) {
}
);
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;
app.get(
"/admin/invites",
[validatedRequest, strictMultiUserRoleValid],
async (_request, response) => {
try {
const invites = await Invite.whereWithUsers();
response.status(200).json({ invites });
} catch (e) {
console.error(e);
response.sendStatus(500).end();
}
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],
[validatedRequest, strictMultiUserRoleValid],
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) {
@ -128,15 +110,9 @@ function adminEndpoints(app) {
app.delete(
"/admin/invite/:id",
[validatedRequest],
[validatedRequest, strictMultiUserRoleValid],
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 });
@ -149,14 +125,9 @@ function adminEndpoints(app) {
app.get(
"/admin/workspaces",
[validatedRequest],
async (request, response) => {
[validatedRequest, strictMultiUserRoleValid],
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) {
@ -168,14 +139,10 @@ function adminEndpoints(app) {
app.post(
"/admin/workspaces/new",
[validatedRequest],
[validatedRequest, strictMultiUserRoleValid],
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,
@ -191,15 +158,9 @@ function adminEndpoints(app) {
app.post(
"/admin/workspaces/:workspaceId/update-users",
[validatedRequest],
[validatedRequest, strictMultiUserRoleValid],
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(
@ -216,15 +177,9 @@ function adminEndpoints(app) {
app.delete(
"/admin/workspaces/:id",
[validatedRequest],
[validatedRequest, strictMultiUserRoleValid],
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 = await Workspace.get({ id: Number(id) });
@ -253,15 +208,9 @@ function adminEndpoints(app) {
app.get(
"/admin/system-preferences",
[validatedRequest],
async (request, response) => {
[validatedRequest, strictMultiUserRoleValid],
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" }))
@ -284,15 +233,9 @@ function adminEndpoints(app) {
app.post(
"/admin/system-preferences",
[validatedRequest],
[validatedRequest, strictMultiUserRoleValid],
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 });
@ -303,39 +246,32 @@ function adminEndpoints(app) {
}
);
app.get("/admin/api-keys", [validatedRequest], async (request, response) => {
try {
const user = await userFromSession(request, response);
if (!user || user?.role !== "admin") {
response.sendStatus(401).end();
return;
app.get(
"/admin/api-keys",
[validatedRequest, strictMultiUserRoleValid],
async (_request, response) => {
try {
const apiKeys = await ApiKey.whereWithUser({});
return response.status(200).json({
apiKeys,
error: null,
});
} catch (error) {
console.error(error);
response.status(500).json({
apiKey: null,
error: "Could not find an API Keys.",
});
}
const apiKeys = await ApiKey.whereWithUser({});
return response.status(200).json({
apiKeys,
error: null,
});
} catch (error) {
console.error(error);
response.status(500).json({
apiKey: null,
error: "Could not find an API Keys.",
});
}
});
);
app.post(
"/admin/generate-api-key",
[validatedRequest],
[validatedRequest, strictMultiUserRoleValid],
async (request, response) => {
try {
const user = await userFromSession(request, response);
if (!user || user?.role !== "admin") {
response.sendStatus(401).end();
return;
}
const { apiKey, error } = await ApiKey.create(user.id);
return response.status(200).json({
apiKey,
@ -350,15 +286,10 @@ function adminEndpoints(app) {
app.delete(
"/admin/delete-api-key/:id",
[validatedRequest],
[validatedRequest, strictMultiUserRoleValid],
async (request, response) => {
try {
const { id } = request.params;
const user = await userFromSession(request, response);
if (!user || user?.role !== "admin") {
response.sendStatus(401).end();
return;
}
await ApiKey.delete({ id: Number(id) });
return response.status(200).end();
} catch (e) {

View File

@ -40,6 +40,7 @@ const { ApiKey } = require("../models/apiKeys");
const { getCustomModels } = require("../utils/helpers/customModels");
const { WorkspaceChats } = require("../models/workspaceChats");
const { Workspace } = require("../models/workspace");
const { flexUserRoleValid } = require("../utils/middleware/multiUserProtected");
function systemEndpoints(app) {
if (!app) return;
@ -244,20 +245,10 @@ function systemEndpoints(app) {
app.post(
"/system/update-env",
[validatedRequest],
[validatedRequest, flexUserRoleValid],
async (request, response) => {
try {
const body = reqBody(request);
// Only admins can update the ENV settings.
if (multiUserMode(response)) {
const user = await userFromSession(request, response);
if (!user || user?.role !== "admin") {
response.sendStatus(401).end();
return;
}
}
const { newValues, error } = updateENV(body);
if (process.env.NODE_ENV === "production") await dumpENV();
response.status(200).json({ newValues, error });
@ -426,7 +417,7 @@ function systemEndpoints(app) {
app.post(
"/system/upload-logo",
[validatedRequest],
[validatedRequest, flexUserRoleValid],
handleLogoUploads.single("logo"),
async (request, response) => {
if (!request.file || !request.file.originalname) {
@ -440,13 +431,6 @@ function systemEndpoints(app) {
}
try {
if (
response.locals.multiUserMode &&
response.locals.user?.role !== "admin"
) {
return response.sendStatus(401).end();
}
const newFilename = await renameLogoFile(request.file.originalname);
const existingLogoFilename = await SystemSettings.currentLogoFilename();
await removeCustomLogo(existingLogoFilename);
@ -480,16 +464,9 @@ function systemEndpoints(app) {
app.get(
"/system/remove-logo",
[validatedRequest],
async (request, response) => {
[validatedRequest, flexUserRoleValid],
async (_request, response) => {
try {
if (
response.locals.multiUserMode &&
response.locals.user?.role !== "admin"
) {
return response.sendStatus(401).end();
}
const currentLogoFilename = await SystemSettings.currentLogoFilename();
await removeCustomLogo(currentLogoFilename);
const { success, error } = await SystemSettings.updateSettings({
@ -517,7 +494,8 @@ function systemEndpoints(app) {
return response.status(200).json({ canDelete: true });
}
if (response.locals.user?.role === "admin") {
const user = await userFromSession(request, response);
if (["admin", "manager"].includes(user?.role)) {
return response.status(200).json({ canDelete: true });
}
@ -548,16 +526,9 @@ function systemEndpoints(app) {
app.post(
"/system/set-welcome-messages",
[validatedRequest],
[validatedRequest, flexUserRoleValid],
async (request, response) => {
try {
if (
response.locals.multiUserMode &&
response.locals.user?.role !== "admin"
) {
return response.sendStatus(401).end();
}
const { messages = [] } = reqBody(request);
if (!Array.isArray(messages)) {
return response.status(400).json({
@ -659,16 +630,9 @@ function systemEndpoints(app) {
app.post(
"/system/workspace-chats",
[validatedRequest],
[validatedRequest, flexUserRoleValid],
async (request, response) => {
try {
if (
response.locals.multiUserMode &&
response.locals.user?.role !== "admin"
) {
return response.sendStatus(401).end();
}
const { offset = 0, limit = 20 } = reqBody(request);
const chats = await WorkspaceChats.whereWithData(
{},
@ -689,16 +653,9 @@ function systemEndpoints(app) {
app.delete(
"/system/workspace-chats/:id",
[validatedRequest],
[validatedRequest, flexUserRoleValid],
async (request, response) => {
try {
if (
response.locals.multiUserMode &&
response.locals.user?.role !== "admin"
) {
return response.sendStatus(401).end();
}
const { id } = request.params;
await WorkspaceChats.delete({ id: Number(id) });
response.status(200).json({ success, error });
@ -711,16 +668,9 @@ function systemEndpoints(app) {
app.get(
"/system/export-chats",
[validatedRequest],
async (request, response) => {
[validatedRequest, flexUserRoleValid],
async (_request, response) => {
try {
if (
response.locals.multiUserMode &&
response.locals.user?.role !== "admin"
) {
return response.sendStatus(401).end();
}
const chats = await WorkspaceChats.whereWithData({}, null, null, {
id: "asc",
});

View File

@ -11,36 +11,40 @@ const {
processDocument,
} = require("../utils/files/documentProcessor");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { SystemSettings } = require("../models/systemSettings");
const { Telemetry } = require("../models/telemetry");
const { flexUserRoleValid } = require("../utils/middleware/multiUserProtected");
const { handleUploads } = setupMulter();
function workspaceEndpoints(app) {
if (!app) return;
app.post("/workspace/new", [validatedRequest], async (request, response) => {
try {
const user = await userFromSession(request, response);
const { name = null, onboardingComplete = false } = reqBody(request);
const { workspace, message } = await Workspace.new(name, user?.id);
await Telemetry.sendTelemetry(
"workspace_created",
{
multiUserMode: multiUserMode(response),
LLMSelection: process.env.LLM_PROVIDER || "openai",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
},
user?.id
);
if (onboardingComplete === true)
await Telemetry.sendTelemetry("onboarding_complete");
app.post(
"/workspace/new",
[validatedRequest, flexUserRoleValid],
async (request, response) => {
try {
const user = await userFromSession(request, response);
const { name = null, onboardingComplete = false } = reqBody(request);
const { workspace, message } = await Workspace.new(name, user?.id);
await Telemetry.sendTelemetry(
"workspace_created",
{
multiUserMode: multiUserMode(response),
LLMSelection: process.env.LLM_PROVIDER || "openai",
VectorDbSelection: process.env.VECTOR_DB || "pinecone",
},
user?.id
);
if (onboardingComplete === true)
await Telemetry.sendTelemetry("onboarding_complete");
response.status(200).json({ workspace, message });
} catch (e) {
console.log(e.message, e);
response.sendStatus(500).end();
response.status(200).json({ workspace, message });
} catch (e) {
console.log(e.message, e);
response.sendStatus(500).end();
}
}
});
);
app.post(
"/workspace/:slug/update",
@ -142,7 +146,7 @@ function workspaceEndpoints(app) {
app.delete(
"/workspace/:slug",
[validatedRequest],
[validatedRequest, flexUserRoleValid],
async (request, response) => {
try {
const { slug = "" } = request.params;
@ -157,16 +161,6 @@ function workspaceEndpoints(app) {
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 WorkspaceChats.delete({ workspaceId: Number(workspace.id) });
await DocumentVectors.deleteForWorkspace(workspace.id);
await Document.delete({ workspaceId: Number(workspace.id) });

View File

@ -64,7 +64,7 @@ const Workspace = {
},
getWithUser: async function (user = null, clause = {}) {
if (user.role === "admin") return this.get(clause);
if (["admin", "manager"].includes(user.role)) return this.get(clause);
try {
const workspace = await prisma.workspaces.findFirst({
@ -142,7 +142,8 @@ const Workspace = {
limit = null,
orderBy = null
) {
if (user.role === "admin") return await this.where(clause, limit, orderBy);
if (["admin", "manager"].includes(user.role))
return await this.where(clause, limit, orderBy);
try {
const workspaces = await prisma.workspaces.findMany({

View File

@ -0,0 +1,41 @@
const { SystemSettings } = require("../../models/systemSettings");
const { userFromSession } = require("../http");
const ROLES = ["admin", "manager"];
// Explicitly check that multi user mode is enabled as well as that the
// requesting user has the appropriate role to modify or call the URL.
async function strictMultiUserRoleValid(request, response, next) {
const multiUserMode =
response.locals?.multiUserMode ?? (await SystemSettings.isMultiUserMode());
if (!multiUserMode) return response.sendStatus(401).end();
const user =
response.locals?.user ?? (await userFromSession(request, response));
if (!ROLES.includes(user?.role)) return response.sendStatus(401).end();
next();
}
// Apply role permission checks IF the current system is in multi-user mode.
// This is relevant for routes that are shared between MUM and single-user mode.
// Checks if the requesting user has the appropriate role to modify or call the URL.
async function flexUserRoleValid(request, response, next) {
const multiUserMode =
response.locals?.multiUserMode ?? (await SystemSettings.isMultiUserMode());
if (!multiUserMode) {
next();
return;
}
const user =
response.locals?.user ?? (await userFromSession(request, response));
if (!ROLES.includes(user?.role)) return response.sendStatus(401).end();
next();
}
module.exports = {
strictMultiUserRoleValid,
flexUserRoleValid,
};

View File

@ -20,7 +20,7 @@ async function validatedRequest(request, response, next) {
}
if (!process.env.AUTH_TOKEN) {
response.status(403).json({
response.status(401).json({
error: "You need to set an AUTH_TOKEN environment variable.",
});
return;
@ -30,7 +30,7 @@ async function validatedRequest(request, response, next) {
const token = auth ? auth.split(" ")[1] : null;
if (!token) {
response.status(403).json({
response.status(401).json({
error: "No auth token found.",
});
return;
@ -38,7 +38,7 @@ async function validatedRequest(request, response, next) {
const { p } = decodeJWT(token);
if (p !== process.env.AUTH_TOKEN) {
response.status(403).json({
response.status(401).json({
error: "Invalid auth token found.",
});
return;
@ -52,7 +52,7 @@ async function validateMultiUserRequest(request, response, next) {
const token = auth ? auth.split(" ")[1] : null;
if (!token) {
response.status(403).json({
response.status(401).json({
error: "No auth token found.",
});
return;
@ -60,7 +60,7 @@ async function validateMultiUserRequest(request, response, next) {
const valid = decodeJWT(token);
if (!valid || !valid.id) {
response.status(403).json({
response.status(401).json({
error: "Invalid auth token.",
});
return;
@ -68,12 +68,19 @@ async function validateMultiUserRequest(request, response, next) {
const user = await User.get({ id: valid.id });
if (!user) {
response.status(403).json({
response.status(401).json({
error: "Invalid auth for user.",
});
return;
}
if (user.suspended) {
response.status(401).json({
error: "User is suspended from system",
});
return;
}
response.locals.user = user;
next();
}