[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>
This commit is contained in:
Sean Hatfield 2024-04-02 14:53:35 -07:00 committed by GitHub
parent e524afae9e
commit 41fe20f2e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 353 additions and 176 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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