Add User Groups (a.k.a. RBAC) (#4)

This commit is contained in:
Chris Weaver
2023-10-09 09:45:07 -07:00
parent 92de6acc6f
commit 7503f8f37b
43 changed files with 2121 additions and 23 deletions

View File

@@ -53,6 +53,8 @@ ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PRED
ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS
ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS}
ARG NEXT_PUBLIC_EE_ENABLED
ENV NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED}
ARG NEXT_PUBLIC_DISABLE_LOGOUT
ENV NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT}
@@ -101,6 +103,8 @@ ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PRED
ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS
ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS}
ARG NEXT_PUBLIC_EE_ENABLED
ENV NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED}
ARG NEXT_PUBLIC_DISABLE_LOGOUT
ENV NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT}

View File

@@ -9,17 +9,31 @@ const nextConfig = {
output: "standalone",
swcMinify: true,
rewrites: async () => {
const eeRedirects =
process.env.NEXT_PUBLIC_EE_ENABLED === "true"
? [
{
source: "/admin/groups",
destination: "/ee/admin/groups",
},
{
source: "/admin/groups/:path*",
destination: "/ee/admin/groups/:path*",
},
]
: [];
// In production, something else (nginx in the one box setup) should take
// care of this rewrite. TODO (chris): better support setups where
// web_server and api_server are on different machines.
if (process.env.NODE_ENV === "production") return [];
if (process.env.NODE_ENV === "production") return eeRedirects;
return [
{
source: "/api/:path*",
destination: "http://127.0.0.1:8080/:path*", // Proxy to Backend
},
];
].concat(eeRedirects);
},
redirects: async () => {
// In production, something else (nginx in the one box setup) should take

View File

@@ -17,7 +17,7 @@ import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { getDocsProcessedPerMinute } from "@/lib/indexAttempt";
import Link from "next/link";
import { isCurrentlyDeleting } from "@/lib/documentDeletion";
import { FiEdit } from "react-icons/fi";
import { FiCheck, FiEdit, FiXCircle } from "react-icons/fi";
const NUM_IN_PAGE = 20;
@@ -86,6 +86,7 @@ export function CCPairIndexingStatusTable({
<TableRow>
<TableHeaderCell>Connector</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Is Public</TableHeaderCell>
<TableHeaderCell>Last Indexed</TableHeaderCell>
<TableHeaderCell>Docs Indexed</TableHeaderCell>
</TableRow>
@@ -116,6 +117,13 @@ export function CCPairIndexingStatusTable({
ccPairsIndexingStatus={ccPairsIndexingStatus}
/>
</TableCell>
<TableCell>
{ccPairsIndexingStatus.public_doc ? (
<FiCheck className="my-auto text-emerald-600" size="18" />
) : (
<FiXCircle className="my-auto text-red-600" />
)}
</TableCell>
<TableCell>
{timeAgo(ccPairsIndexingStatus?.last_success) || "-"}
</TableCell>

36
web/src/app/ee/LICENSE Normal file
View File

@@ -0,0 +1,36 @@
The DanswerAI Enterprise license (the “Enterprise License”)
Copyright (c) 2023 DanswerAI, Inc.
With regard to the Danswer Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the DanswerAI Subscription Terms of Service, available
at https://danswer.ai/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and DanswerAI,
and otherwise have a valid Danswer Enterprise license for the
correct number of user seats. Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that DanswerAI
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Danswer Enterprise license for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Danswer Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,64 @@
import { ConnectorIndexingStatus } from "@/lib/types";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
interface ConnectorEditorProps {
selectedCCPairIds: number[];
setSetCCPairIds: (ccPairId: number[]) => void;
allCCPairs: ConnectorIndexingStatus<any, any>[];
}
export const ConnectorEditor = ({
selectedCCPairIds,
setSetCCPairIds,
allCCPairs,
}: ConnectorEditorProps) => {
return (
<div className="mb-3 flex gap-2 flex-wrap">
{allCCPairs
// remove public docs, since they don't make sense as part of a group
.filter((ccPair) => !ccPair.public_doc)
.map((ccPair) => {
const ind = selectedCCPairIds.indexOf(ccPair.cc_pair_id);
let isSelected = ind !== -1;
return (
<div
key={`${ccPair.connector.id}-${ccPair.credential.id}`}
className={
`
px-3
py-1
rounded-lg
border
border-gray-700
w-fit
flex
cursor-pointer ` +
(isSelected ? " bg-gray-600" : " hover:bg-gray-700")
}
onClick={() => {
if (isSelected) {
setSetCCPairIds(
selectedCCPairIds.filter(
(ccPairId) => ccPairId !== ccPair.cc_pair_id
)
);
} else {
setSetCCPairIds([...selectedCCPairIds, ccPair.cc_pair_id]);
}
}}
>
<div className="my-auto">
<ConnectorTitle
connector={ccPair.connector}
ccPairId={ccPair.cc_pair_id}
ccPairName={ccPair.name}
isLink={false}
showMetadata={false}
/>
</div>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { User } from "@/lib/types";
import { useState } from "react";
import { FiPlus, FiX } from "react-icons/fi";
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
import { UsersIcon } from "@/components/icons/icons";
import { Button } from "@/components/Button";
interface UserEditorProps {
selectedUserIds: string[];
setSelectedUserIds: (userIds: string[]) => void;
allUsers: User[];
existingUsers: User[];
onSubmit?: (users: User[]) => void;
}
export const UserEditor = ({
selectedUserIds,
setSelectedUserIds,
allUsers,
existingUsers,
onSubmit,
}: UserEditorProps) => {
const selectedUsers = allUsers.filter((user) =>
selectedUserIds.includes(user.id)
);
return (
<>
<div className="mb-2 flex flex-wrap gap-x-2">
{selectedUsers.length > 0 &&
selectedUsers.map((selectedUser) => (
<div
key={selectedUser.id}
onClick={() => {
setSelectedUserIds(
selectedUserIds.filter((userId) => userId !== selectedUser.id)
);
}}
className={`
flex
rounded-lg
px-2
py-1
border
border-gray-700
hover:bg-gray-900
cursor-pointer`}
>
{selectedUser.email} <FiX className="ml-1 my-auto" />
</div>
))}
</div>
<div className="flex">
<SearchMultiSelectDropdown
options={allUsers
.filter(
(user) =>
!selectedUserIds.includes(user.id) &&
!existingUsers.map((user) => user.id).includes(user.id)
)
.map((user) => {
return {
name: user.email,
value: user.id,
};
})}
onSelect={(option) => {
setSelectedUserIds([
...Array.from(new Set([...selectedUserIds, option.value])),
]);
}}
itemComponent={({ option }) => (
<div className="flex px-4 py-2.5 hover:bg-gray-800 cursor-pointer">
<UsersIcon className="mr-2 my-auto" />
{option.name}
<div className="ml-auto my-auto">
<FiPlus />
</div>
</div>
)}
/>
{onSubmit && (
<Button
className="ml-3 flex-nowrap w-32"
onClick={() => onSubmit(selectedUsers)}
>
Add Users
</Button>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,145 @@
import { ArrayHelpers, FieldArray, Form, Formik } from "formik";
import * as Yup from "yup";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { ConnectorIndexingStatus, DocumentSet, User } from "@/lib/types";
import { TextFormField } from "@/components/admin/connectors/Field";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { createUserGroup } from "./lib";
import { UserGroup } from "./types";
import { UserEditor } from "./UserEditor";
import { ConnectorEditor } from "./ConnectorEditor";
interface UserGroupCreationFormProps {
onClose: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
users: User[];
ccPairs: ConnectorIndexingStatus<any, any>[];
existingUserGroup?: UserGroup;
}
export const UserGroupCreationForm = ({
onClose,
setPopup,
users,
ccPairs,
existingUserGroup,
}: UserGroupCreationFormProps) => {
const isUpdate = existingUserGroup !== undefined;
return (
<div>
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-30"
onClick={onClose}
>
<div
className="bg-gray-800 rounded-lg border border-gray-700 shadow-lg relative w-1/2 text-sm"
onClick={(event) => event.stopPropagation()}
>
<Formik
initialValues={{
name: existingUserGroup ? existingUserGroup.name : "",
user_ids: [] as string[],
cc_pair_ids: [] as number[],
}}
validationSchema={Yup.object().shape({
name: Yup.string().required("Please enter a name for the group"),
user_ids: Yup.array().of(Yup.string().required()),
cc_pair_ids: Yup.array().of(Yup.number().required()),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
let response;
response = await createUserGroup(values);
formikHelpers.setSubmitting(false);
if (response.ok) {
setPopup({
message: isUpdate
? "Successfully updated user group!"
: "Successfully created user group!",
type: "success",
});
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: isUpdate
? `Error updating user group - ${errorMsg}`
: `Error creating user group - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting, values, setFieldValue }) => (
<Form>
<h2 className="text-xl font-bold mb-3 border-b border-gray-600 pt-4 pb-3 bg-gray-700 px-6">
{isUpdate ? "Update a User Group" : "Create a new User Group"}
</h2>
<div className="p-4">
<TextFormField
name="name"
label="Name:"
placeholder="A name for the User Group"
disabled={isUpdate}
autoCompleteDisabled={true}
/>
<div className="border-t border-gray-600 py-2" />
<h2 className="mb-1 font-medium">
Select which connectors this group has access to:
</h2>
<p className="mb-3 text-xs">
All documents indexed by the selected connectors will be
visible to users in this group.
</p>
<ConnectorEditor
allCCPairs={ccPairs}
selectedCCPairIds={values.cc_pair_ids}
setSetCCPairIds={(ccPairsIds) =>
setFieldValue("cc_pair_ids", ccPairsIds)
}
/>
<div className="border-t border-gray-600 py-2" />
<h2 className="mb-1 font-medium">
Select which Users should be a part of this Group.
</h2>
<p className="mb-3 text-xs">
All selected users will be able to search through all
documents indexed by the selected connectors.
</p>
<div className="mb-3 gap-2">
<UserEditor
selectedUserIds={values.user_ids}
setSelectedUserIds={(userIds) =>
setFieldValue("user_ids", userIds)
}
allUsers={users}
existingUsers={[]}
/>
</div>
<div className="flex">
<button
type="submit"
disabled={isSubmitting}
className={
"bg-slate-500 hover:bg-slate-700 text-white " +
"font-bold py-2 px-4 rounded focus:outline-none " +
"focus:shadow-outline w-full max-w-sm mx-auto"
}
>
{isUpdate ? "Update!" : "Create!"}
</button>
</div>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,161 @@
"use client";
import { UserGroup } from "./types";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { LoadingAnimation } from "@/components/Loading";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { TrashIcon } from "@/components/icons/icons";
import { deleteUserGroup } from "./lib";
import { useRouter } from "next/navigation";
import { FiUser } from "react-icons/fi";
import { User } from "@/lib/types";
const MAX_USERS_TO_DISPLAY = 6;
const SimpleUserDisplay = ({ user }: { user: User }) => {
return (
<div className="flex my-0.5 text-gray-200">
<FiUser className="mr-2 my-auto" /> {user.email}
</div>
);
};
interface UserGroupsTableProps {
userGroups: UserGroup[];
setPopup: (popupSpec: PopupSpec | null) => void;
refresh: () => void;
}
export const UserGroupsTable = ({
userGroups,
setPopup,
refresh,
}: UserGroupsTableProps) => {
const router = useRouter();
// sort by name for consistent ordering
userGroups.sort((a, b) => {
if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
} else {
return 0;
}
});
return (
<div>
<BasicTable
columns={[
{
header: "Name",
key: "name",
},
{
header: "Connectors",
key: "ccPairs",
},
{
header: "Users",
key: "users",
},
{
header: "Status",
key: "status",
},
{
header: "Delete",
key: "delete",
},
]}
data={userGroups
.filter((userGroup) => !userGroup.is_up_for_deletion)
.map((userGroup) => {
return {
id: userGroup.id,
name: userGroup.name,
ccPairs: (
<div>
{userGroup.cc_pairs.map((ccPairDescriptor, ind) => {
return (
<div
className={
ind !== userGroup.cc_pairs.length - 1 ? "mb-3" : ""
}
key={ccPairDescriptor.id}
>
<ConnectorTitle
connector={ccPairDescriptor.connector}
ccPairId={ccPairDescriptor.id}
ccPairName={ccPairDescriptor.name}
showMetadata={false}
/>
</div>
);
})}
</div>
),
users: (
<div>
{userGroup.users.length <= MAX_USERS_TO_DISPLAY ? (
userGroup.users.map((user) => {
return <SimpleUserDisplay key={user.id} user={user} />;
})
) : (
<div>
{userGroup.users
.slice(0, MAX_USERS_TO_DISPLAY)
.map((user) => {
return (
<SimpleUserDisplay key={user.id} user={user} />
);
})}
<div className="text-gray-300">
+ {userGroup.users.length - MAX_USERS_TO_DISPLAY} more
</div>
</div>
)}
</div>
),
status: userGroup.is_up_to_date ? (
<div className="text-emerald-600">Up to date!</div>
) : (
<div className="text-gray-300 w-10">
<LoadingAnimation text="Syncing" />
</div>
),
delete: (
<div
className="cursor-pointer"
onClick={async (event) => {
event.stopPropagation();
const response = await deleteUserGroup(userGroup.id);
if (response.ok) {
setPopup({
message: `User Group "${userGroup.name}" deleted`,
type: "success",
});
} else {
const errorMsg = (await response.json()).detail;
setPopup({
message: `Failed to delete User Group - ${errorMsg}`,
type: "error",
});
}
refresh();
}}
>
<TrashIcon />
</div>
),
};
})}
onSelect={(data) => {
router.push(`/admin/groups/${data.id}`);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,154 @@
import { Button } from "@/components/Button";
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
import { Modal } from "@/components/Modal";
import { UsersIcon } from "@/components/icons/icons";
import { useState } from "react";
import { FiPlus, FiX } from "react-icons/fi";
import { updateUserGroup } from "./lib";
import { UserGroup } from "../types";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { Connector, ConnectorIndexingStatus } from "@/lib/types";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
interface AddConnectorFormProps {
ccPairs: ConnectorIndexingStatus<any, any>[];
userGroup: UserGroup;
onClose: () => void;
setPopup: (popupSpec: PopupSpec) => void;
}
export const AddConnectorForm: React.FC<AddConnectorFormProps> = ({
ccPairs,
userGroup,
onClose,
setPopup,
}) => {
const [selectedCCPairIds, setSelectedCCPairIds] = useState<number[]>([]);
const selectedCCPairs = ccPairs.filter((ccPair) =>
selectedCCPairIds.includes(ccPair.cc_pair_id)
);
return (
<Modal title="Add New Connector" onOutsideClick={() => onClose()}>
<div className="px-6 pt-4 pb-12">
<div className="mb-2 flex flex-wrap gap-x-2">
{selectedCCPairs.length > 0 &&
selectedCCPairs.map((ccPair) => (
<div
key={ccPair.cc_pair_id}
onClick={() => {
setSelectedCCPairIds(
selectedCCPairIds.filter(
(ccPairId) => ccPairId !== ccPair.cc_pair_id
)
);
}}
className={`
flex
rounded-lg
px-2
py-1
my-1
border
border-gray-700
hover:bg-gray-900
cursor-pointer`}
>
<ConnectorTitle
ccPairId={ccPair.cc_pair_id}
ccPairName={ccPair.name}
connector={ccPair.connector}
isLink={false}
showMetadata={false}
/>
<FiX className="ml-1 my-auto" />
</div>
))}
</div>
<div className="flex">
<SearchMultiSelectDropdown
options={ccPairs
.filter(
(ccPair) =>
!selectedCCPairIds.includes(ccPair.cc_pair_id) &&
!userGroup.cc_pairs
.map((userGroupCCPair) => userGroupCCPair.id)
.includes(ccPair.cc_pair_id)
)
// remove public docs, since they don't make sense as part of a group
.filter((ccPair) => !ccPair.public_doc)
.map((ccPair) => {
return {
name: ccPair.name?.toString() || "",
value: ccPair.cc_pair_id?.toString(),
metadata: {
ccPairId: ccPair.cc_pair_id,
connector: ccPair.connector,
},
};
})}
onSelect={(option) => {
setSelectedCCPairIds([
...Array.from(
new Set([...selectedCCPairIds, parseInt(option.value)])
),
]);
}}
itemComponent={({ option }) => (
<div className="flex px-4 py-2.5 hover:bg-gray-800 cursor-pointer">
<div className="my-auto">
<ConnectorTitle
ccPairId={option?.metadata?.ccPairId as number}
ccPairName={option.name}
connector={option?.metadata?.connector as Connector<any>}
isLink={false}
showMetadata={false}
/>
</div>
<div className="ml-auto my-auto">
<FiPlus />
</div>
</div>
)}
/>
<Button
className="ml-3 flex-nowrap w-48"
onClick={async () => {
const newCCPairIds = [
...Array.from(
new Set(
userGroup.cc_pairs
.map((ccPair) => ccPair.id)
.concat(selectedCCPairIds)
)
),
];
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users.map((user) => user.id),
cc_pair_ids: newCCPairIds,
});
if (response.ok) {
setPopup({
message: "Successfully added users to group",
type: "success",
});
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to add users to group - ${errorMsg}`,
type: "error",
});
onClose();
}
}}
>
Add Connectors
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,66 @@
import { Modal } from "@/components/Modal";
import { updateUserGroup } from "./lib";
import { UserGroup } from "../types";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { User } from "@/lib/types";
import { UserEditor } from "../UserEditor";
import { useState } from "react";
interface AddMemberFormProps {
users: User[];
userGroup: UserGroup;
onClose: () => void;
setPopup: (popupSpec: PopupSpec) => void;
}
export const AddMemberForm: React.FC<AddMemberFormProps> = ({
users,
userGroup,
onClose,
setPopup,
}) => {
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
return (
<Modal title="Add New User" onOutsideClick={() => onClose()}>
<div className="px-6 pt-4 pb-12">
<UserEditor
selectedUserIds={selectedUserIds}
setSelectedUserIds={setSelectedUserIds}
allUsers={users}
existingUsers={userGroup.users}
onSubmit={async (selectedUsers) => {
const newUserIds = [
...Array.from(
new Set(
userGroup.users
.map((user) => user.id)
.concat(selectedUsers.map((user) => user.id))
)
),
];
const response = await updateUserGroup(userGroup.id, {
user_ids: newUserIds,
cc_pair_ids: userGroup.cc_pairs.map((ccPair) => ccPair.id),
});
if (response.ok) {
setPopup({
message: "Successfully added users to group",
type: "success",
});
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to add users to group - ${errorMsg}`,
type: "error",
});
onClose();
}
}}
/>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,225 @@
"use client";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { UserGroup } from "../types";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { Button } from "@/components/Button";
import { AddMemberForm } from "./AddMemberForm";
import { TrashIcon } from "@/components/icons/icons";
import { updateUserGroup } from "./lib";
import { LoadingAnimation } from "@/components/Loading";
import { ConnectorIndexingStatus, User } from "@/lib/types";
import { AddConnectorForm } from "./AddConnectorForm";
interface GroupDisplayProps {
users: User[];
ccPairs: ConnectorIndexingStatus<any, any>[];
userGroup: UserGroup;
refreshUserGroup: () => void;
}
export const GroupDisplay = ({
users,
ccPairs,
userGroup,
refreshUserGroup,
}: GroupDisplayProps) => {
const { popup, setPopup } = usePopup();
const [addMemberFormVisible, setAddMemberFormVisible] = useState(false);
const [addConnectorFormVisible, setAddConnectorFormVisible] = useState(false);
return (
<div>
{popup}
<div className="text-sm mb-3 flex">
<b className="mr-1">Status:</b>{" "}
{userGroup.is_up_to_date ? (
<div className="text-emerald-600">Up to date</div>
) : (
<div className="text-gray-300">
<LoadingAnimation text="Syncing" />
</div>
)}
</div>
<div className="flex w-full">
<h2 className="text-xl font-bold">Users</h2>
</div>
<div className="mt-2">
{userGroup.users.length > 0 ? (
<BasicTable
columns={[
{
header: "Email",
key: "email",
},
{
header: "Remove User",
key: "remove",
alignment: "right",
},
]}
data={userGroup.users.map((user) => {
return {
email: <div>{user.email}</div>,
remove: (
<div className="flex">
<div
className="cursor-pointer ml-auto mr-1"
onClick={async () => {
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users
.filter(
(userGroupUser) => userGroupUser.id !== user.id
)
.map((userGroupUser) => userGroupUser.id),
cc_pair_ids: userGroup.cc_pairs.map(
(ccPair) => ccPair.id
),
});
if (response.ok) {
setPopup({
message: "Successfully removed user from group",
type: "success",
});
} else {
const responseJson = await response.json();
const errorMsg =
responseJson.detail || responseJson.message;
setPopup({
message: `Error removing user from group - ${errorMsg}`,
type: "error",
});
}
refreshUserGroup();
}}
>
<TrashIcon />
</div>
</div>
),
};
})}
/>
) : (
<div className="text-sm">No users in this group...</div>
)}
</div>
<Button
className="mt-3"
onClick={() => setAddMemberFormVisible(true)}
disabled={!userGroup.is_up_to_date}
>
Add Users
</Button>
{addMemberFormVisible && (
<AddMemberForm
users={users}
userGroup={userGroup}
onClose={() => {
setAddMemberFormVisible(false);
refreshUserGroup();
}}
setPopup={setPopup}
/>
)}
<h2 className="text-xl font-bold mt-4">Connectors</h2>
<div className="mt-2">
{userGroup.cc_pairs.length > 0 ? (
<BasicTable
columns={[
{
header: "Connector",
key: "connector_name",
},
{
header: "Remove Connector",
key: "delete",
alignment: "right",
},
]}
data={userGroup.cc_pairs.map((ccPair) => {
return {
connector_name:
(
<ConnectorTitle
connector={ccPair.connector}
ccPairId={ccPair.id}
ccPairName={ccPair.name}
/>
) || "",
delete: (
<div className="flex">
<div
className="cursor-pointer ml-auto mr-1"
onClick={async () => {
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users.map(
(userGroupUser) => userGroupUser.id
),
cc_pair_ids: userGroup.cc_pairs
.filter(
(userGroupCCPair) =>
userGroupCCPair.id != ccPair.id
)
.map((ccPair) => ccPair.id),
});
if (response.ok) {
setPopup({
message:
"Successfully removed connector from group",
type: "success",
});
} else {
const responseJson = await response.json();
const errorMsg =
responseJson.detail || responseJson.message;
setPopup({
message: `Error removing connector from group - ${errorMsg}`,
type: "error",
});
}
refreshUserGroup();
}}
>
<TrashIcon />
</div>
</div>
),
};
})}
/>
) : (
<div className="text-sm">No connectors in this group...</div>
)}
</div>
<Button
className="mt-3"
onClick={() => setAddConnectorFormVisible(true)}
disabled={!userGroup.is_up_to_date}
>
Add Connectors
</Button>
{addConnectorFormVisible && (
<AddConnectorForm
ccPairs={ccPairs}
userGroup={userGroup}
onClose={() => {
setAddConnectorFormVisible(false);
refreshUserGroup();
}}
setPopup={setPopup}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,12 @@
import { useUserGroups } from "../hooks";
export const useSpecificUserGroup = (groupId: string) => {
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
const userGroup = data?.find((group) => group.id.toString() === groupId);
return {
userGroup,
isLoading,
error,
refreshUserGroup: refreshUserGroups,
};
};

View File

@@ -0,0 +1,15 @@
import { UserGroupUpdate } from "../types";
export const updateUserGroup = async (
groupId: number,
userGroup: UserGroupUpdate
) => {
const url = `/api/manage/admin/user-group/${groupId}`;
return await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userGroup),
});
};

View File

@@ -0,0 +1,81 @@
"use client";
import { GroupsIcon } from "@/components/icons/icons";
import { GroupDisplay } from "./GroupDisplay";
import { FiAlertCircle, FiChevronLeft } from "react-icons/fi";
import { useSpecificUserGroup } from "./hook";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorCredentialIndexingStatus, useUsers } from "@/lib/hooks";
import { useRouter } from "next/navigation";
const Page = ({ params }: { params: { groupId: string } }) => {
const router = useRouter();
const {
userGroup,
isLoading: userGroupIsLoading,
error: userGroupError,
refreshUserGroup,
} = useSpecificUserGroup(params.groupId);
const {
data: users,
isLoading: userIsLoading,
error: usersError,
} = useUsers();
const {
data: ccPairs,
isLoading: isCCPairsLoading,
error: ccPairsError,
} = useConnectorCredentialIndexingStatus();
if (userGroupIsLoading || userIsLoading || isCCPairsLoading) {
return (
<div className="h-full">
<div className="my-auto">
<ThreeDotsLoader />
</div>
</div>
);
}
if (!userGroup || userGroupError) {
return <div>Error loading user group</div>;
}
if (!users || usersError) {
return <div>Error loading users</div>;
}
if (!ccPairs || ccPairsError) {
return <div>Error loading connectors</div>;
}
return (
<div className="mx-auto container">
<div
className="my-auto flex mb-1 hover:bg-gray-700 w-fit pr-2 cursor-pointer rounded-lg"
onClick={() => router.back()}
>
<FiChevronLeft className="mr-1 my-auto" />
Back
</div>
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<GroupsIcon size={32} />
<h1 className="text-3xl font-bold pl-2">
{userGroup ? userGroup.name : <FiAlertCircle />}
</h1>
</div>
{userGroup ? (
<GroupDisplay
users={users}
ccPairs={ccPairs}
userGroup={userGroup}
refreshUserGroup={refreshUserGroup}
/>
) : (
<div>Unable to fetch User Group :(</div>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,14 @@
import useSWR, { mutate } from "swr";
import { UserGroup } from "./types";
import { errorHandlingFetcher } from "@/lib/fetcher";
const USER_GROUP_URL = "/api/manage/admin/user-group";
export const useUserGroups = () => {
const swrResponse = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher);
return {
...swrResponse,
refreshUserGroups: () => mutate(USER_GROUP_URL),
};
};

View File

@@ -0,0 +1,17 @@
import { UserGroupCreation } from "./types";
export const createUserGroup = async (userGroup: UserGroupCreation) => {
return fetch("/api/manage/admin/user-group", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userGroup),
});
};
export const deleteUserGroup = async (userGroupId: number) => {
return fetch(`/api/manage/admin/user-group/${userGroupId}`, {
method: "DELETE",
});
};

View File

@@ -0,0 +1,86 @@
"use client";
import { GroupsIcon } from "@/components/icons/icons";
import { UserGroupsTable } from "./UserGroupsTable";
import { UserGroupCreationForm } from "./UserGroupCreationForm";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { Button } from "@/components/Button";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorCredentialIndexingStatus, useUsers } from "@/lib/hooks";
import { useUserGroups } from "./hooks";
const Main = () => {
const { popup, setPopup } = usePopup();
const [showForm, setShowForm] = useState(false);
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
const {
data: ccPairs,
isLoading: isCCPairsLoading,
error: ccPairsError,
} = useConnectorCredentialIndexingStatus();
const {
data: users,
isLoading: userIsLoading,
error: usersError,
} = useUsers();
if (isLoading || isCCPairsLoading || userIsLoading) {
return <ThreeDotsLoader />;
}
if (error || !data) {
return <div className="text-red-600">Error loading users</div>;
}
if (ccPairsError || !ccPairs) {
return <div className="text-red-600">Error loading connectors</div>;
}
if (usersError || !users) {
return <div className="text-red-600">Error loading users</div>;
}
return (
<>
{popup}
<div className="my-3">
<Button onClick={() => setShowForm(true)}>Create New User Group</Button>
</div>
<UserGroupsTable
userGroups={data}
setPopup={setPopup}
refresh={refreshUserGroups}
/>
{showForm && (
<UserGroupCreationForm
onClose={() => {
refreshUserGroups();
setShowForm(false);
}}
setPopup={setPopup}
users={users}
ccPairs={ccPairs}
/>
)}
</>
);
};
const Page = () => {
return (
<div className="mx-auto container">
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<GroupsIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Manage Users Groups</h1>
</div>
<Main />
</div>
);
};
export default Page;

View File

@@ -0,0 +1,21 @@
import { CCPairDescriptor, User } from "@/lib/types";
export interface UserGroupUpdate {
user_ids: string[];
cc_pair_ids: number[];
}
export interface UserGroup {
id: number;
name: string;
users: User[];
cc_pairs: CCPairDescriptor<any, any>[];
is_up_to_date: boolean;
is_up_for_deletion: boolean;
}
export interface UserGroupCreation {
name: string;
user_ids: string[];
cc_pair_ids: number[];
}

View File

@@ -0,0 +1,11 @@
/* Duplicate of `/app/admin/layout.tsx */
import { Layout } from "@/components/admin/Layout";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return await Layout({ children });
}

19
web/src/app/ee/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { EE_ENABLED } from "@/lib/constants";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
if (!EE_ENABLED) {
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">
This funcitonality is only available in the Enterprise Edition :(
</div>
</div>
);
}
return children;
}

View File

@@ -8,6 +8,7 @@ import {
ZoomInIcon,
RobotIcon,
ConnectorIcon,
GroupsIcon,
} from "@/components/icons/icons";
import { User } from "@/lib/types";
import {
@@ -15,6 +16,7 @@ import {
getAuthTypeMetadataSS,
getCurrentUserSS,
} from "@/lib/userSS";
import { EE_ENABLED } from "@/lib/constants";
import { redirect } from "next/navigation";
import { FiCpu, FiPackage, FiSettings, FiSlack, FiTool } from "react-icons/fi";
@@ -179,6 +181,19 @@ export async function Layout({ children }: { children: React.ReactNode }) {
),
link: "/admin/users",
},
...(EE_ENABLED
? [
{
name: (
<div className="flex">
<GroupsIcon size={18} />
<div className="ml-1">Groups</div>
</div>
),
link: "/admin/groups",
},
]
: []),
],
},
{

View File

@@ -10,10 +10,11 @@ import {
} from "@/lib/types";
import { deleteConnectorIfExistsAndIsUnlinked } from "@/lib/connector";
import { FormBodyBuilder, RequireAtLeastOne } from "./types";
import { TextFormField } from "./Field";
import { BooleanFormField, TextFormField } from "./Field";
import { createCredential, linkCredential } from "@/lib/credential";
import { useSWRConfig } from "swr";
import { Button } from "@tremor/react";
import { EE_ENABLED } from "@/lib/constants";
const BASE_CONNECTOR_URL = "/api/manage/admin/connector";
@@ -74,6 +75,7 @@ interface BaseProps<T extends Yup.AnyObject> {
// If specified, then we will create an empty credential and associate
// the connector with it. If credentialId is specified, then this will be ignored
shouldCreateEmptyCredentialForConnector?: boolean;
showNonPublicOption?: boolean;
}
type ConnectorFormProps<T extends Yup.AnyObject> = RequireAtLeastOne<
@@ -95,26 +97,44 @@ export function ConnectorForm<T extends Yup.AnyObject>({
pruneFreq,
onSubmit,
shouldCreateEmptyCredentialForConnector,
// only show this option for EE, since groups are not supported in CE
showNonPublicOption = EE_ENABLED,
}: ConnectorFormProps<T>): JSX.Element {
const { mutate } = useSWRConfig();
const { popup, setPopup } = usePopup();
const shouldHaveNameInput = credentialId !== undefined && !ccPairNameBuilder;
const ccPairNameInitialValue = shouldHaveNameInput
? { cc_pair_name: "" }
: {};
const publicOptionInitialValue = showNonPublicOption
? { is_public: false }
: {};
let finalValidationSchema =
validationSchema as Yup.ObjectSchema<Yup.AnyObject>;
if (shouldHaveNameInput) {
finalValidationSchema = finalValidationSchema.concat(CCPairNameHaver);
}
if (showNonPublicOption) {
finalValidationSchema = finalValidationSchema.concat(
Yup.object().shape({
is_public: Yup.boolean(),
})
);
}
return (
<>
{popup}
<Formik
initialValues={
shouldHaveNameInput
? { cc_pair_name: "", ...initialValues }
: initialValues
}
validationSchema={
shouldHaveNameInput
? validationSchema.concat(CCPairNameHaver)
: validationSchema
}
initialValues={{
...publicOptionInitialValue,
...ccPairNameInitialValue,
...initialValues,
}}
validationSchema={finalValidationSchema}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
const connectorName = nameBuilder(values);
@@ -185,7 +205,8 @@ export function ConnectorForm<T extends Yup.AnyObject>({
const linkCredentialResponse = await linkCredential(
response.id,
credentialIdToLinkTo,
ccPairName
ccPairName as string,
values.is_public
);
if (!linkCredentialResponse.ok) {
const linkCredentialErrorMsg =
@@ -222,6 +243,22 @@ export function ConnectorForm<T extends Yup.AnyObject>({
)}
{formBody && formBody}
{formBodyBuilder && formBodyBuilder(values)}
{showNonPublicOption && (
<>
<div className="border-t border-gray-600 py-2" />
<BooleanFormField
name="is_public"
label="Documents are Public?"
subtext={
"If set, then documents indexed by this connector will be " +
"visible to all users. If turned off, then only users who explicitly " +
"have been given access to the documents (e.g. through a User Group) " +
"will have access"
}
/>
<div className="border-t border-gray-600 py-2" />
</>
)}
<div className="flex">
<Button
type="submit"

View File

@@ -1,5 +1,4 @@
import { ConnectorIndexingStatus, Credential } from "@/lib/types";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { LinkBreakIcon, LinkIcon } from "@/components/icons/icons";
@@ -14,6 +13,7 @@ import {
TableBody,
TableCell,
} from "@tremor/react";
import { FiCheck, FiXCircle } from "react-icons/fi";
interface StatusRowProps<ConnectorConfigType, ConnectorCredentialType> {
connectorIndexingStatus: ConnectorIndexingStatus<
@@ -141,6 +141,10 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
header: "Status",
key: "status",
},
{
header: "Is Public",
key: "is_public",
},
];
if (connectorIncludesCredential) {
columns.push({
@@ -165,6 +169,7 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
<TableHeaderCell key={header}>{header}</TableHeaderCell>
))}
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Is Public</TableHeaderCell>
{connectorIncludesCredential && (
<TableHeaderCell>Credential</TableHeaderCell>
)}
@@ -217,6 +222,13 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
onUpdate={onUpdate}
/>
</TableCell>
<TableCell>
{connectorIndexingStatus.public_doc ? (
<FiCheck className="my-auto text-success" size="18" />
) : (
<FiXCircle className="my-auto text-error" />
)}
</TableCell>
{connectorIncludesCredential && (
<TableCell>{credentialDisplay}</TableCell>
)}

View File

@@ -11,7 +11,6 @@ import {
Brain,
X,
Question,
Users,
Gear,
ArrowSquareOut,
} from "@phosphor-icons/react";
@@ -36,6 +35,7 @@ import {
FiCpu,
FiInfo,
FiUploadCloud,
FiUser,
FiUsers,
} from "react-icons/fi";
import { SiBookstack } from "react-icons/si";
@@ -94,7 +94,7 @@ export const UsersIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <Users size={size} className={className} />;
return <FiUser size={size} className={className} />;
};
export const GroupsIcon = ({

View File

@@ -31,7 +31,8 @@ export async function deleteCredential<T>(credentialId: number) {
export function linkCredential(
connectorId: number,
credentialId: number,
name?: string
name?: string,
isPublic?: boolean
) {
return fetch(
`/api/manage/connector/${connectorId}/credential/${credentialId}`,
@@ -40,7 +41,10 @@ export function linkCredential(
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: name || null }),
body: JSON.stringify({
name: name || null,
is_public: isPublic !== undefined ? isPublic : true, // default to public
}),
}
);
}