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