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 && ( )} {currUser?.id !== user.id && canModify && ( <> 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 ( - +
+ - - - ); } 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 ( +
+
+
+
+

Users

+
+ + +
+
+ +
+
+
+
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 ( -
-
-
-

- Edit {workspace.name} -

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

Error: {error}

} -
-
-
- - -
-
-
-
- ); -} diff --git a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx 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 }) { {workspace.slug} -
{workspace.userIds?.length} + + {workspace.userIds?.length} + + {workspace.createdAt} -
+ {filteredUsers.length > 0 ? ( + filteredUsers.map((user) => ( + handleUserSelect(user.id)} + > +
+ {isUserSelected(user.id) && ( +
+ )} +
+

+ {user.username} +

+
+ )) + ) : ( +

+ No users found +

+ )} +
+ +
+
+ + +
+ +
+ + + + ); +} 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 ( + <> + + + {user.username} + + {titleCase(user.role)} + {user.lastUpdatedAt} + + + ); +} 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 ( + + ); + } + + return ( +
+ + + + + + + + + + + {workspaceUsers.length > 0 ? ( + workspaceUsers.map((user, index) => ( + + )) + ) : ( + + + + )} + +
+ Username + + Role + + Date Added + + {" "} +
+ No workspace members +
+ + + + + + +
+ ); +} 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={} to={paths.workspace.settings.vectorDatabase(slug)} /> + } + to={paths.workspace.settings.members(slug)} + />
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) });