From 41fe20f2e0b4d6311dbfcdc1b8a181cbf5f59c31 Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Tue, 2 Apr 2024 14:53:35 -0700 Subject: [PATCH] [FEAT] Implement new workspace members settings and admin users UI updates (#990) * members workspace settings menu and admin users UI updates * change copy/fix admin and managers in workspace when not showing in UI * remove existing workspace user mgmt modal --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- frontend/src/models/admin.js | 12 ++ .../src/pages/Admin/Users/UserRow/index.jsx | 9 +- frontend/src/pages/Admin/Users/index.jsx | 2 +- .../EditWorkspaceUsersModal/index.jsx | 149 ---------------- .../Admin/Workspaces/WorkspaceRow/index.jsx | 28 +-- frontend/src/pages/Admin/Workspaces/index.jsx | 2 - .../Members/AddMemberModal/index.jsx | 162 ++++++++++++++++++ .../Members/WorkspaceMemberRow/index.jsx | 15 ++ .../pages/WorkspaceSettings/Members/index.jsx | 97 +++++++++++ .../src/pages/WorkspaceSettings/index.jsx | 8 + frontend/src/utils/paths.js | 3 + server/endpoints/admin.js | 15 ++ server/models/workspace.js | 27 +++ 13 files changed, 353 insertions(+), 176 deletions(-) delete mode 100644 frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx create mode 100644 frontend/src/pages/WorkspaceSettings/Members/AddMemberModal/index.jsx create mode 100644 frontend/src/pages/WorkspaceSettings/Members/WorkspaceMemberRow/index.jsx create mode 100644 frontend/src/pages/WorkspaceSettings/Members/index.jsx diff --git a/frontend/src/models/admin.js b/frontend/src/models/admin.js index df27ac02c..c8fe2cc97 100644 --- a/frontend/src/models/admin.js +++ b/frontend/src/models/admin.js @@ -104,6 +104,18 @@ const Admin = { return []; }); }, + workspaceUsers: async (workspaceId) => { + return await fetch(`${API_BASE}/admin/workspaces/${workspaceId}/users`, { + method: "GET", + headers: baseHeaders(), + }) + .then((res) => res.json()) + .then((res) => res?.users || []) + .catch((e) => { + console.error(e); + return []; + }); + }, newWorkspace: async (name) => { return await fetch(`${API_BASE}/admin/workspaces/new`, { method: "POST", diff --git a/frontend/src/pages/Admin/Users/UserRow/index.jsx b/frontend/src/pages/Admin/Users/UserRow/index.jsx index c58b8124b..720ae2dcc 100644 --- a/frontend/src/pages/Admin/Users/UserRow/index.jsx +++ b/frontend/src/pages/Admin/Users/UserRow/index.jsx @@ -2,7 +2,6 @@ import { useRef, useState } from "react"; import { titleCase } from "text-case"; import Admin from "@/models/admin"; import EditUserModal from "./EditUserModal"; -import { DotsThreeOutline } from "@phosphor-icons/react"; import showToast from "@/utils/toast"; import { useModal } from "@/hooks/useModal"; import ModalWrapper from "@/components/ModalWrapper"; @@ -69,22 +68,22 @@ export default function UserRow({ currUser, user }) { {canModify && ( <button onClick={openModal} - 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" + className="text-sm font-medium text-white/80 rounded-lg hover:text-white px-2 py-1 hover:bg-white hover:bg-opacity-10" > - <DotsThreeOutline weight="fill" className="h-5 w-5" /> + Edit </button> )} {currUser?.id !== user.id && canModify && ( <> <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" + className="text-sm font-medium text-white/80 hover:text-orange-300 rounded-lg px-2 py-1 hover:bg-white hover:bg-opacity-10" > {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" + className="text-sm font-medium text-white/80 hover:text-red-300 px-2 py-1 rounded-lg hover:bg-red-800 hover:bg-opacity-20" > Delete </button> diff --git a/frontend/src/pages/Admin/Users/index.jsx b/frontend/src/pages/Admin/Users/index.jsx index 78d85c812..6824ee219 100644 --- a/frontend/src/pages/Admin/Users/index.jsx +++ b/frontend/src/pages/Admin/Users/index.jsx @@ -77,7 +77,7 @@ function UsersContainer() { } return ( - <table className="w-full text-sm text-left rounded-lg mt-6"> + <table className="w-full text-sm text-left rounded-lg"> <thead className="text-white text-opacity-80 text-xs leading-[18px] font-bold uppercase border-white border-b border-opacity-60"> <tr> <th scope="col" className="px-6 py-3 rounded-tl-lg"> diff --git a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx deleted file mode 100644 index 1f1ff9a92..000000000 --- a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/EditWorkspaceUsersModal/index.jsx +++ /dev/null @@ -1,149 +0,0 @@ -import React, { useState } from "react"; -import { X } from "@phosphor-icons/react"; -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, - closeModal, -}) { - const [error, setError] = useState(null); - - 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 ( - <div className="relative w-[500px] max-w-2xl max-h-full"> - <div className="relative bg-main-gradient rounded-lg shadow"> - <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50"> - <h3 className="text-xl font-semibold text-white"> - Edit {workspace.name} - </h3> - <button - onClick={closeModal} - type="button" - className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - 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 max-h-[350px] overflow-y-scroll"> - {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-500/50 rounded group hover:bg-stone-900 transition-all duration-300 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-zinc-900 border border-gray-500/50 rounded focus:ring-blue-500 focus:border-blue-500 pointer-events-none" - /> - <label - htmlFor={`user-${user.id}`} - className="pointer-events-none w-full py-4 ml-2 text-sm font-medium text-white" - > - {titleCase(user.username)} - </label> - </div> - ); - })} - <div className="flex items-center gap-x-4"> - <button - type="button" - className="w-full p-4 flex text-white items-center pl-4 border border-gray-500/50 rounded group hover:bg-stone-900 transition-all duration-300 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 text-white items-center pl-4 border border-gray-500/50 rounded group hover:bg-stone-900 transition-all duration-300 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-400 text-sm">Error: {error}</p>} - </div> - </div> - <div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50"> - <button - onClick={closeModal} - type="button" - className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300" - > - Cancel - </button> - <button - type="submit" - className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800" - > - Update workspace - </button> - </div> - </form> - </div> - </div> - ); -} diff --git a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx index e755e1857..a54e027b8 100644 --- a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx +++ b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx @@ -1,14 +1,10 @@ import { useRef } from "react"; import Admin from "@/models/admin"; import paths from "@/utils/paths"; -import EditWorkspaceUsersModal from "./EditWorkspaceUsersModal"; -import { DotsThreeOutline, LinkSimple, Trash } from "@phosphor-icons/react"; -import { useModal } from "@/hooks/useModal"; -import ModalWrapper from "@/components/ModalWrapper"; +import { LinkSimple, Trash } from "@phosphor-icons/react"; export default function WorkspaceRow({ workspace, users }) { const rowRef = useRef(null); - const { isOpen, openModal, closeModal } = useModal(); const handleDelete = async () => { if ( !window.confirm( @@ -39,15 +35,16 @@ export default function WorkspaceRow({ workspace, users }) { <LinkSimple className="mr-2 w-5 h-5" /> {workspace.slug} </a> </td> - <td className="px-6 py-4">{workspace.userIds?.length}</td> + <td className="px-6 py-4"> + <a + href={paths.workspace.settings.members(workspace.slug)} + className="text-white flex items-center underline" + > + {workspace.userIds?.length} + </a> + </td> <td className="px-6 py-4">{workspace.createdAt}</td> <td className="px-6 py-4 flex items-center gap-x-6"> - <button - onClick={openModal} - className="font-medium rounded-lg hover:text-white hover:text-opacity-60 px-2 py-1 hover:bg-white hover:bg-opacity-10" - > - <DotsThreeOutline weight="fill" className="h-5 w-5" /> - </button> <button onClick={handleDelete} className="font-medium text-red-300 px-2 py-1 rounded-lg hover:bg-red-800 hover:bg-opacity-20" @@ -56,13 +53,6 @@ export default function WorkspaceRow({ workspace, users }) { </button> </td> </tr> - <ModalWrapper isOpen={isOpen}> - <EditWorkspaceUsersModal - workspace={workspace} - users={users} - closeModal={closeModal} - /> - </ModalWrapper> </> ); } diff --git a/frontend/src/pages/Admin/Workspaces/index.jsx b/frontend/src/pages/Admin/Workspaces/index.jsx index 0085c32b2..63b9fb346 100644 --- a/frontend/src/pages/Admin/Workspaces/index.jsx +++ b/frontend/src/pages/Admin/Workspaces/index.jsx @@ -4,7 +4,6 @@ import { isMobile } from "react-device-detect"; import * as Skeleton from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { BookOpen } from "@phosphor-icons/react"; -import usePrefersDarkMode from "@/hooks/usePrefersDarkMode"; import Admin from "@/models/admin"; import WorkspaceRow from "./WorkspaceRow"; import NewWorkspaceModal from "./NewWorkspaceModal"; @@ -50,7 +49,6 @@ export default function AdminWorkspaces() { } function WorkspacesContainer() { - const darkMode = usePrefersDarkMode(); const [loading, setLoading] = useState(true); const [users, setUsers] = useState([]); const [workspaces, setWorkspaces] = useState([]); diff --git a/frontend/src/pages/WorkspaceSettings/Members/AddMemberModal/index.jsx b/frontend/src/pages/WorkspaceSettings/Members/AddMemberModal/index.jsx new file mode 100644 index 000000000..0799e5486 --- /dev/null +++ b/frontend/src/pages/WorkspaceSettings/Members/AddMemberModal/index.jsx @@ -0,0 +1,162 @@ +import React, { useState } from "react"; +import { MagnifyingGlass, X } from "@phosphor-icons/react"; +import Admin from "@/models/admin"; +import showToast from "@/utils/toast"; + +export default function AddMemberModal({ closeModal, workspace, users }) { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedUsers, setSelectedUsers] = useState(workspace?.userIds || []); + + const handleUpdate = async (e) => { + e.preventDefault(); + const { success, error } = await Admin.updateUsersInWorkspace( + workspace.id, + selectedUsers + ); + if (success) { + showToast("Users updated successfully.", "success"); + setTimeout(() => { + window.location.reload(); + }, 1000); + } + showToast(error, "error"); + }; + + const handleUserSelect = (userId) => { + setSelectedUsers((prevSelectedUsers) => { + if (prevSelectedUsers.includes(userId)) { + return prevSelectedUsers.filter((id) => id !== userId); + } else { + return [...prevSelectedUsers, userId]; + } + }); + }; + + const handleSelectAll = () => { + if (selectedUsers.length === filteredUsers.length) { + setSelectedUsers([]); + } else { + setSelectedUsers(filteredUsers.map((user) => user.id)); + } + }; + + const handleUnselect = () => { + setSelectedUsers([]); + }; + + const isUserSelected = (userId) => { + return selectedUsers.includes(userId); + }; + + const handleSearch = (event) => { + setSearchTerm(event.target.value); + }; + + const filteredUsers = users + .filter((user) => + user.username.toLowerCase().includes(searchTerm.toLowerCase()) + ) + .filter((user) => user.role !== "admin") + .filter((user) => user.role !== "manager"); + + return ( + <div className="relative w-full max-w-[550px] max-h-full"> + <div className="relative bg-main-gradient rounded-xl shadow-[0_4px_14px_rgba(0,0,0,0.25)]"> + <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50"> + <div className="flex items-center gap-x-4"> + <h3 className="text-base font-semibold text-white">Users</h3> + <div className="relative"> + <input + onChange={handleSearch} + className="w-[400px] h-[34px] bg-[#030712] rounded-[100px] text-white placeholder:text-white/50 text-sm px-10 pl-10" + placeholder="Search for a user" + /> + <MagnifyingGlass + size={16} + weight="bold" + className="text-white text-lg absolute left-3 top-1/2 transform -translate-y-1/2" + /> + </div> + </div> + <button + onClick={closeModal} + type="button" + className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" + data-modal-hide="staticModal" + > + <X className="text-gray-300 text-lg" /> + </button> + </div> + <form onSubmit={handleUpdate}> + <div className="py-[17px] px-[20px]"> + <table className="gap-y-[8px] flex flex-col max-h-[385px] overflow-y-auto no-scroll"> + {filteredUsers.length > 0 ? ( + filteredUsers.map((user) => ( + <tr + key={user.id} + className="flex items-center gap-x-2 cursor-pointer" + onClick={() => handleUserSelect(user.id)} + > + <div + className="shrink-0 w-3 h-3 rounded border-[1px] border-white flex justify-center items-center" + role="checkbox" + aria-checked={isUserSelected(user.id)} + tabIndex={0} + > + {isUserSelected(user.id) && ( + <div className="w-2 h-2 bg-white rounded-[2px]" /> + )} + </div> + <p className="text-white text-sm font-medium"> + {user.username} + </p> + </tr> + )) + ) : ( + <p className="text-white text-opacity-60 text-sm font-medium "> + No users found + </p> + )} + </table> + </div> + <div className="flex w-full justify-between items-center p-3 space-x-2 border-t rounded-b border-gray-500/50"> + <div className="flex items-center gap-x-2"> + <button + type="button" + onClick={handleSelectAll} + className="flex items-center gap-x-2 ml-2" + > + <div + className="shrink-0 w-3 h-3 rounded border-[1px] border-white flex justify-center items-center cursor-pointer" + role="checkbox" + aria-checked={selectedUsers.length === filteredUsers.length} + tabIndex={0} + > + {selectedUsers.length === filteredUsers.length && ( + <div className="w-2 h-2 bg-white rounded-[2px]" /> + )} + </div> + <p className="text-white text-sm font-medium">Select All</p> + </button> + <button + type="button" + onClick={handleUnselect} + className="flex items-center gap-x-2 ml-2" + > + <p className="text-white/60 text-sm font-medium hover:text-white"> + Unselect + </p> + </button> + </div> + <button + type="submit" + className="transition-all duration-300 text-xs px-2 py-1 font-semibold rounded-lg bg-[#46C8FF] hover:bg-[#2C2F36] border-2 border-transparent hover:border-[#46C8FF] hover:text-white h-[32px] w-[68px] -mr-8 whitespace-nowrap shadow-[0_4px_14px_rgba(0,0,0,0.25)]" + > + Save + </button> + </div> + </form> + </div> + </div> + ); +} diff --git a/frontend/src/pages/WorkspaceSettings/Members/WorkspaceMemberRow/index.jsx b/frontend/src/pages/WorkspaceSettings/Members/WorkspaceMemberRow/index.jsx new file mode 100644 index 000000000..4da5b7c3e --- /dev/null +++ b/frontend/src/pages/WorkspaceSettings/Members/WorkspaceMemberRow/index.jsx @@ -0,0 +1,15 @@ +import { titleCase } from "text-case"; + +export default function WorkspaceMemberRow({ user }) { + return ( + <> + <tr className="bg-transparent text-white text-opacity-80 text-sm font-medium"> + <th scope="row" className="px-6 py-4 whitespace-nowrap"> + {user.username} + </th> + <td className="px-6 py-4">{titleCase(user.role)}</td> + <td className="px-6 py-4">{user.lastUpdatedAt}</td> + </tr> + </> + ); +} diff --git a/frontend/src/pages/WorkspaceSettings/Members/index.jsx b/frontend/src/pages/WorkspaceSettings/Members/index.jsx new file mode 100644 index 000000000..f315619a4 --- /dev/null +++ b/frontend/src/pages/WorkspaceSettings/Members/index.jsx @@ -0,0 +1,97 @@ +import ModalWrapper from "@/components/ModalWrapper"; +import { useModal } from "@/hooks/useModal"; +import Admin from "@/models/admin"; +import { useEffect, useState } from "react"; +import * as Skeleton from "react-loading-skeleton"; +import AddMemberModal from "./AddMemberModal"; +import WorkspaceMemberRow from "./WorkspaceMemberRow"; + +export default function Members({ workspace }) { + const [loading, setLoading] = useState(true); + const [users, setUsers] = useState([]); + const [workspaceUsers, setWorkspaceUsers] = useState([]); + const [adminWorkspace, setAdminWorkspace] = useState(null); + + const { isOpen, openModal, closeModal } = useModal(); + useEffect(() => { + async function fetchData() { + const _users = await Admin.users(); + const workspaceUsers = await Admin.workspaceUsers(workspace.id); + const adminWorkspaces = await Admin.workspaces(); + setAdminWorkspace( + adminWorkspaces.find( + (adminWorkspace) => adminWorkspace.id === workspace.id + ) + ); + setWorkspaceUsers(workspaceUsers); + setUsers(_users); + setLoading(false); + } + fetchData(); + }, [workspace]); + + if (loading) { + return ( + <Skeleton.default + height="80vh" + width="100%" + highlightColor="#3D4147" + baseColor="#2C2F35" + count={1} + className="w-full p-4 rounded-b-2xl rounded-tr-2xl rounded-tl-sm mt-6" + containerClassName="flex w-full" + /> + ); + } + + return ( + <div className="flex justify-between -mt-3"> + <table className="w-full max-w-[700px] text-sm text-left rounded-lg"> + <thead className="text-white text-opacity-80 text-xs leading-[18px] font-bold uppercase border-white border-b border-opacity-60"> + <tr> + <th scope="col" className="px-6 py-3 rounded-tl-lg"> + Username + </th> + <th scope="col" className="px-6 py-3"> + Role + </th> + <th scope="col" className="px-6 py-3"> + Date Added + </th> + <th scope="col" className="px-6 py-3 rounded-tr-lg"> + {" "} + </th> + </tr> + </thead> + <tbody> + {workspaceUsers.length > 0 ? ( + workspaceUsers.map((user, index) => ( + <WorkspaceMemberRow key={index} user={user} /> + )) + ) : ( + <tr> + <td className="text-center py-4 text-white/80" colSpan="4"> + No workspace members + </td> + </tr> + )} + </tbody> + </table> + + <button + onClick={openModal} + className="text-xs px-2 py-1 font-semibold rounded-lg bg-[#46C8FF] hover:bg-[#2C2F36] hover:text-white h-[34px] w-[100px] -mr-8 whitespace-nowrap shadow-[0_4px_14px_rgba(0,0,0,0.25)]" + > + Manage Users + </button> + + <ModalWrapper isOpen={isOpen}> + <AddMemberModal + closeModal={closeModal} + users={users} + workspace={adminWorkspace} + /> + </ModalWrapper> + </div> + ); +} diff --git a/frontend/src/pages/WorkspaceSettings/index.jsx b/frontend/src/pages/WorkspaceSettings/index.jsx index 952860ade..1ee44f7db 100644 --- a/frontend/src/pages/WorkspaceSettings/index.jsx +++ b/frontend/src/pages/WorkspaceSettings/index.jsx @@ -9,6 +9,7 @@ import { ArrowUUpLeft, ChatText, Database, + User, Wrench, } from "@phosphor-icons/react"; import paths from "@/utils/paths"; @@ -17,11 +18,13 @@ import { NavLink } from "react-router-dom"; import GeneralAppearance from "./GeneralAppearance"; import ChatSettings from "./ChatSettings"; import VectorDatabase from "./VectorDatabase"; +import Members from "./Members"; const TABS = { "general-appearance": GeneralAppearance, "chat-settings": ChatSettings, "vector-database": VectorDatabase, + members: Members, }; export default function WorkspaceSettings() { @@ -91,6 +94,11 @@ function ShowWorkspaceChat() { icon={<Database className="h-6 w-6" />} to={paths.workspace.settings.vectorDatabase(slug)} /> + <TabItem + title="Members" + icon={<User className="h-6 w-6" />} + to={paths.workspace.settings.members(slug)} + /> </div> <div className="px-16 py-6"> <TabContent slug={slug} workspace={workspace} /> diff --git a/frontend/src/utils/paths.js b/frontend/src/utils/paths.js index 5625fafb9..e496211fa 100644 --- a/frontend/src/utils/paths.js +++ b/frontend/src/utils/paths.js @@ -65,6 +65,9 @@ export default { vectorDatabase: (slug) => { return `/workspace/${slug}/settings/vector-database`; }, + members: (slug) => { + return `/workspace/${slug}/settings/members`; + }, }, thread: (wsSlug, threadSlug) => { return `/workspace/${wsSlug}/t/${threadSlug}`; diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index f55cbb6e7..34bd66c3f 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -227,6 +227,21 @@ function adminEndpoints(app) { } ); + app.get( + "/admin/workspaces/:workspaceId/users", + [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + async (request, response) => { + try { + const { workspaceId } = request.params; + const users = await Workspace.workspaceUsers(workspaceId); + response.status(200).json({ users }); + } catch (e) { + console.error(e); + response.sendStatus(500).end(); + } + } + ); + app.post( "/admin/workspaces/new", [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], diff --git a/server/models/workspace.js b/server/models/workspace.js index 7056468d1..f061ca206 100644 --- a/server/models/workspace.js +++ b/server/models/workspace.js @@ -4,6 +4,7 @@ const { Document } = require("./documents"); const { WorkspaceUser } = require("./workspaceUsers"); const { ROLES } = require("../utils/middleware/multiUserProtected"); const { v4: uuidv4 } = require("uuid"); +const { User } = require("./user"); const Workspace = { defaultPrompt: @@ -191,6 +192,32 @@ const Workspace = { } }, + workspaceUsers: async function (workspaceId) { + try { + const users = ( + await WorkspaceUser.where({ workspace_id: Number(workspaceId) }) + ).map((rel) => rel); + + const usersById = await User.where({ + id: { in: users.map((user) => user.user_id) }, + }); + + const userInfo = usersById.map((user) => { + const workspaceUser = users.find((u) => u.user_id === user.id); + return { + username: user.username, + role: user.role, + lastUpdatedAt: workspaceUser.lastUpdatedAt, + }; + }); + + return userInfo; + } catch (error) { + console.error(error.message); + return []; + } + }, + updateUsers: async function (workspaceId, userIds = []) { try { await WorkspaceUser.delete({ workspace_id: Number(workspaceId) }); -- GitLab