mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-22 17:16:20 +02:00
Add User Groups (a.k.a. RBAC) (#4)
This commit is contained in:
@@ -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}
|
||||
|
@@ -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
|
||||
|
@@ -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
36
web/src/app/ee/LICENSE
Normal 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.
|
64
web/src/app/ee/admin/groups/ConnectorEditor.tsx
Normal file
64
web/src/app/ee/admin/groups/ConnectorEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
94
web/src/app/ee/admin/groups/UserEditor.tsx
Normal file
94
web/src/app/ee/admin/groups/UserEditor.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
145
web/src/app/ee/admin/groups/UserGroupCreationForm.tsx
Normal file
145
web/src/app/ee/admin/groups/UserGroupCreationForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
161
web/src/app/ee/admin/groups/UserGroupsTable.tsx
Normal file
161
web/src/app/ee/admin/groups/UserGroupsTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
154
web/src/app/ee/admin/groups/[groupId]/AddConnectorForm.tsx
Normal file
154
web/src/app/ee/admin/groups/[groupId]/AddConnectorForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
66
web/src/app/ee/admin/groups/[groupId]/AddMemberForm.tsx
Normal file
66
web/src/app/ee/admin/groups/[groupId]/AddMemberForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
225
web/src/app/ee/admin/groups/[groupId]/GroupDisplay.tsx
Normal file
225
web/src/app/ee/admin/groups/[groupId]/GroupDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
12
web/src/app/ee/admin/groups/[groupId]/hook.ts
Normal file
12
web/src/app/ee/admin/groups/[groupId]/hook.ts
Normal 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,
|
||||
};
|
||||
};
|
15
web/src/app/ee/admin/groups/[groupId]/lib.ts
Normal file
15
web/src/app/ee/admin/groups/[groupId]/lib.ts
Normal 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),
|
||||
});
|
||||
};
|
81
web/src/app/ee/admin/groups/[groupId]/page.tsx
Normal file
81
web/src/app/ee/admin/groups/[groupId]/page.tsx
Normal 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;
|
14
web/src/app/ee/admin/groups/hooks.ts
Normal file
14
web/src/app/ee/admin/groups/hooks.ts
Normal 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),
|
||||
};
|
||||
};
|
17
web/src/app/ee/admin/groups/lib.ts
Normal file
17
web/src/app/ee/admin/groups/lib.ts
Normal 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",
|
||||
});
|
||||
};
|
86
web/src/app/ee/admin/groups/page.tsx
Normal file
86
web/src/app/ee/admin/groups/page.tsx
Normal 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;
|
21
web/src/app/ee/admin/groups/types.ts
Normal file
21
web/src/app/ee/admin/groups/types.ts
Normal 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[];
|
||||
}
|
11
web/src/app/ee/admin/layout.tsx
Normal file
11
web/src/app/ee/admin/layout.tsx
Normal 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
19
web/src/app/ee/layout.tsx
Normal 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;
|
||||
}
|
@@ -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",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
)}
|
||||
|
@@ -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 = ({
|
||||
|
@@ -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
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user