diff --git a/backend/danswer/auth/invited_users.py b/backend/danswer/auth/invited_users.py index fb30332af..ff3a8cce9 100644 --- a/backend/danswer/auth/invited_users.py +++ b/backend/danswer/auth/invited_users.py @@ -9,7 +9,6 @@ from danswer.utils.special_types import JSON_ro def get_invited_users() -> list[str]: try: store = get_kv_store() - return cast(list, store.load(KV_USER_STORE_KEY)) except KvKeyNotFoundError: return list() diff --git a/backend/danswer/server/manage/models.py b/backend/danswer/server/manage/models.py index 8d3955e0f..0e37fc891 100644 --- a/backend/danswer/server/manage/models.py +++ b/backend/danswer/server/manage/models.py @@ -266,5 +266,7 @@ class FullModelVersionResponse(BaseModel): class AllUsersResponse(BaseModel): accepted: list[FullUserSnapshot] invited: list[InvitedUserSnapshot] + slack_users: list[FullUserSnapshot] accepted_pages: int invited_pages: int + slack_users_pages: int diff --git a/backend/danswer/server/manage/users.py b/backend/danswer/server/manage/users.py index 33162b934..75fd9dfe3 100644 --- a/backend/danswer/server/manage/users.py +++ b/backend/danswer/server/manage/users.py @@ -119,6 +119,7 @@ def set_user_role( def list_all_users( q: str | None = None, accepted_page: int | None = None, + slack_users_page: int | None = None, invited_page: int | None = None, user: User | None = Depends(current_curator_or_admin_user), db_session: Session = Depends(get_session), @@ -131,7 +132,12 @@ def list_all_users( for user in list_users(db_session, email_filter_string=q) if not is_api_key_email_address(user.email) ] - accepted_emails = {user.email for user in users} + + slack_users = [user for user in users if user.role == UserRole.SLACK_USER] + accepted_users = [user for user in users if user.role != UserRole.SLACK_USER] + + accepted_emails = {user.email for user in accepted_users} + slack_users_emails = {user.email for user in slack_users} invited_emails = get_invited_users() if q: invited_emails = [ @@ -139,10 +145,11 @@ def list_all_users( ] accepted_count = len(accepted_emails) + slack_users_count = len(slack_users_emails) invited_count = len(invited_emails) # If any of q, accepted_page, or invited_page is None, return all users - if accepted_page is None or invited_page is None: + if accepted_page is None or invited_page is None or slack_users_page is None: return AllUsersResponse( accepted=[ FullUserSnapshot( @@ -153,11 +160,23 @@ def list_all_users( UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED ), ) - for user in users + for user in accepted_users + ], + slack_users=[ + FullUserSnapshot( + id=user.id, + email=user.email, + role=user.role, + status=( + UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED + ), + ) + for user in slack_users ], invited=[InvitedUserSnapshot(email=email) for email in invited_emails], accepted_pages=1, invited_pages=1, + slack_users_pages=1, ) # Otherwise, return paginated results @@ -169,13 +188,27 @@ def list_all_users( role=user.role, status=UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED, ) - for user in users + for user in accepted_users ][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE], + slack_users=[ + FullUserSnapshot( + id=user.id, + email=user.email, + role=user.role, + status=UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED, + ) + for user in slack_users + ][ + slack_users_page + * USERS_PAGE_SIZE : (slack_users_page + 1) + * USERS_PAGE_SIZE + ], invited=[InvitedUserSnapshot(email=email) for email in invited_emails][ invited_page * USERS_PAGE_SIZE : (invited_page + 1) * USERS_PAGE_SIZE ], accepted_pages=accepted_count // USERS_PAGE_SIZE + 1, invited_pages=invited_count // USERS_PAGE_SIZE + 1, + slack_users_pages=slack_users_count // USERS_PAGE_SIZE + 1, ) diff --git a/backend/tests/integration/common_utils/managers/tenant.py b/backend/tests/integration/common_utils/managers/tenant.py index fc411018d..c25a1b2ec 100644 --- a/backend/tests/integration/common_utils/managers/tenant.py +++ b/backend/tests/integration/common_utils/managers/tenant.py @@ -69,8 +69,10 @@ class TenantManager: return AllUsersResponse( accepted=[FullUserSnapshot(**user) for user in data["accepted"]], invited=[InvitedUserSnapshot(**user) for user in data["invited"]], + slack_users=[FullUserSnapshot(**user) for user in data["slack_users"]], accepted_pages=data["accepted_pages"], invited_pages=data["invited_pages"], + slack_users_pages=data["slack_users_pages"], ) @staticmethod diff --git a/backend/tests/integration/common_utils/managers/user.py b/backend/tests/integration/common_utils/managers/user.py index 43286c6a7..26cb29cdf 100644 --- a/backend/tests/integration/common_utils/managers/user.py +++ b/backend/tests/integration/common_utils/managers/user.py @@ -130,8 +130,10 @@ class UserManager: all_users = AllUsersResponse( accepted=[FullUserSnapshot(**user) for user in data["accepted"]], invited=[InvitedUserSnapshot(**user) for user in data["invited"]], + slack_users=[FullUserSnapshot(**user) for user in data["slack_users"]], accepted_pages=data["accepted_pages"], invited_pages=data["invited_pages"], + slack_users_pages=data["slack_users_pages"], ) for accepted_user in all_users.accepted: if accepted_user.email == user.email and accepted_user.id == user.id: diff --git a/web/src/app/admin/users/page.tsx b/web/src/app/admin/users/page.tsx index 8dbc6c308..e4ffca942 100644 --- a/web/src/app/admin/users/page.tsx +++ b/web/src/app/admin/users/page.tsx @@ -1,13 +1,13 @@ "use client"; +import { useEffect, useState } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import InvitedUserTable from "@/components/admin/users/InvitedUserTable"; import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable"; import { SearchBar } from "@/components/search/SearchBar"; -import { useState } from "react"; import { FiPlusSquare } from "react-icons/fi"; import { Modal } from "@/components/Modal"; - -import { Button } from "@/components/ui/button"; -import Text from "@/components/ui/text"; import { LoadingAnimation } from "@/components/Loading"; import { AdminPageTitle } from "@/components/admin/Title"; import { usePopup, PopupSpec } from "@/components/admin/connectors/Popup"; @@ -15,42 +15,10 @@ import { UsersIcon } from "@/components/icons/icons"; import { errorHandlingFetcher } from "@/lib/fetcher"; import useSWR, { mutate } from "swr"; import { ErrorCallout } from "@/components/ErrorCallout"; -import { HidableSection } from "@/app/admin/assistants/HidableSection"; import BulkAdd from "@/components/admin/users/BulkAdd"; import { UsersResponse } from "@/lib/users/interfaces"; - -const ValidDomainsDisplay = ({ validDomains }: { validDomains: string[] }) => { - if (!validDomains.length) { - return ( -
- No invited users. Anyone can sign up with a valid email address. To - restrict access you can: -
- (1) Invite users above. Once a user has been invited, only emails that - have explicitly been invited will be able to sign-up. -
-
- (2) Set the{" "} - VALID_EMAIL_DOMAINS{" "} - environment variable to a comma separated list of email domains. This - will restrict access to users with email addresses from these domains. -
-
- ); - } - - return ( -
- No invited users. Anyone with an email address with any of the following - domains can sign up: {validDomains.join(", ")}. -
- To further restrict access you can invite users above. Once a user has - been invited, only emails that have explicitly been invited will be able - to sign-up. -
-
- ); -}; +import SlackUserTable from "@/components/admin/users/SlackUserTable"; +import Text from "@/components/ui/text"; const UsersTables = ({ q, @@ -61,23 +29,48 @@ const UsersTables = ({ }) => { const [invitedPage, setInvitedPage] = useState(1); const [acceptedPage, setAcceptedPage] = useState(1); - const { data, isLoading, mutate, error } = useSWR( - `/api/manage/users?q=${encodeURI(q)}&accepted_page=${ + const [slackUsersPage, setSlackUsersPage] = useState(1); + + const [usersData, setUsersData] = useState( + undefined + ); + const [domainsData, setDomainsData] = useState( + undefined + ); + + const { data, error, mutate } = useSWR( + `/api/manage/users?q=${encodeURIComponent(q)}&accepted_page=${ acceptedPage - 1 - }&invited_page=${invitedPage - 1}`, + }&invited_page=${invitedPage - 1}&slack_users_page=${slackUsersPage - 1}`, errorHandlingFetcher ); - const { - data: validDomains, - isLoading: isLoadingDomains, - error: domainsError, - } = useSWR("/api/manage/admin/valid-domains", errorHandlingFetcher); - if (isLoading || isLoadingDomains) { + const { data: validDomains, error: domainsError } = useSWR( + "/api/manage/admin/valid-domains", + errorHandlingFetcher + ); + + useEffect(() => { + if (data) { + setUsersData(data); + } + }, [data]); + + useEffect(() => { + if (validDomains) { + setDomainsData(validDomains); + } + }, [validDomains]); + + const activeData = data ?? usersData; + const activeDomains = validDomains ?? domainsData; + + // Show loading animation only during the initial data fetch + if (!activeData || !activeDomains) { return ; } - if (error || !data) { + if (error) { return ( !accepted.map((u) => u.email).includes(user.email) + (user) => !accepted.some((u) => u.email === user.email) ); return ( - <> - - {invited.length > 0 ? ( - finalInvited.length > 0 ? ( - - ) : ( -
- To invite additional teammates, use the Invite Users button - above! -
- ) - ) : ( - - )} -
- - + + + Invited Users + Current Users + DanswerBot Users + + + + + + Invited Users + + + {finalInvited.length > 0 ? ( + + ) : ( +

Users that have been invited will show up here

+ )} +
+
+
+ + + + + Current Users + + + {accepted.length > 0 ? ( + + ) : ( +

Users that have an account will show up here

+ )} +
+
+
+ + + + + DanswerBot Users + + + {slack_users.length > 0 ? ( + + ) : ( +

Slack-only users will show up here

+ )} +
+
+
+
); }; @@ -215,6 +257,7 @@ const Page = () => { return (
} /> +
); diff --git a/web/src/components/admin/users/InvitedUserTable.tsx b/web/src/components/admin/users/InvitedUserTable.tsx index ac50ac15b..43c2a8948 100644 --- a/web/src/components/admin/users/InvitedUserTable.tsx +++ b/web/src/components/admin/users/InvitedUserTable.tsx @@ -6,14 +6,13 @@ import { TableBody, TableCell, } from "@/components/ui/table"; -import { Button } from "@/components/ui/button"; -import userMutationFetcher from "@/lib/admin/users/userMutationFetcher"; + import CenteredPageSelector from "./CenteredPageSelector"; import { type PageSelectorProps } from "@/components/PageSelector"; import { type User } from "@/lib/types"; -import useSWRMutation from "swr/mutation"; import { TableHeader } from "@/components/ui/table"; +import { InviteUserButton } from "./buttons/InviteUserButton"; interface Props { users: Array; @@ -21,27 +20,6 @@ interface Props { mutate: () => void; } -const RemoveUserButton = ({ - user, - onSuccess, - onError, -}: { - user: User; - onSuccess: () => void; - onError: (message: string) => void; -}) => { - const { trigger } = useSWRMutation( - "/api/manage/admin/remove-invited-user", - userMutationFetcher, - { onSuccess, onError } - ); - return ( - - ); -}; - const InvitedUserTable = ({ users, setPopup, @@ -52,20 +30,6 @@ const InvitedUserTable = ({ }: Props & PageSelectorProps) => { if (!users.length) return null; - const onRemovalSuccess = () => { - mutate(); - setPopup({ - message: "User uninvited!", - type: "success", - }); - }; - const onRemovalError = (errorMsg: string) => { - setPopup({ - message: `Unable to uninvite user - ${errorMsg}`, - type: "error", - }); - }; - return ( <> @@ -83,10 +47,11 @@ const InvitedUserTable = ({ {user.email}
-
diff --git a/web/src/components/admin/users/SignedUpUserTable.tsx b/web/src/components/admin/users/SignedUpUserTable.tsx index caae9383a..b04c0141b 100644 --- a/web/src/components/admin/users/SignedUpUserTable.tsx +++ b/web/src/components/admin/users/SignedUpUserTable.tsx @@ -1,16 +1,7 @@ -import { - type User, - UserStatus, - UserRole, - USER_ROLE_LABELS, - INVALID_ROLE_HOVER_TEXT, -} from "@/lib/types"; +import { type User, UserStatus, UserRole } from "@/lib/types"; import CenteredPageSelector from "./CenteredPageSelector"; import { type PageSelectorProps } from "@/components/PageSelector"; -import { HidableSection } from "@/app/admin/assistants/HidableSection"; import { PopupSpec } from "@/components/admin/connectors/Popup"; -import userMutationFetcher from "@/lib/admin/users/userMutationFetcher"; -import useSWRMutation from "swr/mutation"; import { Table, TableHead, @@ -18,20 +9,10 @@ import { TableBody, TableCell, } from "@/components/ui/table"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; -import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal"; -import { useState } from "react"; -import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; -import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal"; import { TableHeader } from "@/components/ui/table"; +import { UserRoleDropdown } from "./buttons/UserRoleDropdown"; +import { DeleteUserButton } from "./buttons/DeleteUserButton"; +import { DeactivaterButton } from "./buttons/DeactivaterButton"; interface Props { users: Array; @@ -39,204 +20,6 @@ interface Props { mutate: () => void; } -const UserRoleDropdown = ({ - user, - onSuccess, - onError, -}: { - user: User; - onSuccess: () => void; - onError: (message: string) => void; -}) => { - const [showConfirmModal, setShowConfirmModal] = useState(false); - const [pendingRole, setPendingRole] = useState(null); - - const { trigger: setUserRole, isMutating: isSettingRole } = useSWRMutation( - "/api/manage/set-user-role", - userMutationFetcher, - { onSuccess, onError } - ); - const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled(); - - const handleChange = (value: string) => { - if (value === user.role) return; - if (user.role === UserRole.CURATOR) { - setShowConfirmModal(true); - setPendingRole(value); - } else { - setUserRole({ - user_email: user.email, - new_role: value, - }); - } - }; - - const handleConfirm = () => { - if (pendingRole) { - setUserRole({ - user_email: user.email, - new_role: pendingRole, - }); - } - setShowConfirmModal(false); - setPendingRole(null); - }; - - return ( - <> - - {showConfirmModal && ( - setShowConfirmModal(false)} - onConfirm={handleConfirm} - /> - )} - - ); -}; - -const DeactivaterButton = ({ - user, - deactivate, - setPopup, - mutate, -}: { - user: User; - deactivate: boolean; - setPopup: (spec: PopupSpec) => void; - mutate: () => void; -}) => { - const { trigger, isMutating } = useSWRMutation( - deactivate - ? "/api/manage/admin/deactivate-user" - : "/api/manage/admin/activate-user", - userMutationFetcher, - { - onSuccess: () => { - mutate(); - setPopup({ - message: `User ${deactivate ? "deactivated" : "activated"}!`, - type: "success", - }); - }, - onError: (errorMsg) => - setPopup({ message: errorMsg.message, type: "error" }), - } - ); - return ( - - ); -}; - -const DeleteUserButton = ({ - user, - setPopup, - mutate, -}: { - user: User; - setPopup: (spec: PopupSpec) => void; - mutate: () => void; -}) => { - const { trigger, isMutating } = useSWRMutation( - "/api/manage/admin/delete-user", - userMutationFetcher, - { - onSuccess: () => { - mutate(); - setPopup({ - message: "User deleted successfully!", - type: "success", - }); - }, - onError: (errorMsg) => - setPopup({ - message: `Unable to delete user - ${errorMsg}`, - type: "error", - }), - } - ); - - const [showDeleteModal, setShowDeleteModal] = useState(false); - return ( - <> - {showDeleteModal && ( - setShowDeleteModal(false)} - onSubmit={() => trigger({ user_email: user.email, method: "DELETE" })} - additionalDetails="All data associated with this user will be deleted (including personas, tools and chat sessions)." - /> - )} - - - - ); -}; - const SignedUpUserTable = ({ users, setPopup, @@ -258,68 +41,66 @@ const SignedUpUserTable = ({ handlePopup(`Unable to update user role - ${errorMsg}`, "error"); return ( - - <> - {totalPages > 1 ? ( - - ) : null} -
- - - Email - Role - Status - -
-
Actions
-
-
-
-
- - {users - // Dont want to show external permissioned users because it's scary - .filter((user) => user.role !== UserRole.EXT_PERM_USER) - .map((user) => ( - - {user.email} - - + {totalPages > 1 ? ( + + ) : null} +
+ + + Email + Role + Status + +
+
Actions
+
+
+
+
+ + {users + // Dont want to show external permissioned users because it's scary + .filter((user) => user.role !== UserRole.EXT_PERM_USER) + .map((user) => ( + + {user.email} + + + + + {user.status === "live" ? "Active" : "Inactive"} + + +
+ - - - {user.status === "live" ? "Active" : "Inactive"} - - -
- - {user.status == UserStatus.deactivated && ( - - )} -
-
- - ))} - -
- - + )} + + + + ))} + + + ); }; diff --git a/web/src/components/admin/users/SlackUserTable.tsx b/web/src/components/admin/users/SlackUserTable.tsx new file mode 100644 index 000000000..ce6d6a911 --- /dev/null +++ b/web/src/components/admin/users/SlackUserTable.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { User } from "@/lib/types"; +import { + Table, + TableCell, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { PopupSpec } from "../connectors/Popup"; +import { InviteUserButton } from "./buttons/InviteUserButton"; +import { PageSelectorProps } from "@/components/PageSelector"; +import CenteredPageSelector from "./CenteredPageSelector"; + +interface SlackUserTableProps { + invitedUsers: User[]; + slackusers: User[]; + mutate: () => void; + setPopup: (spec: PopupSpec) => void; +} + +const SlackUserTable: React.FC = ({ + invitedUsers, + slackusers, + mutate, + currentPage, + totalPages, + onPageChange, + setPopup, +}) => { + return ( + <> + {totalPages > 1 ? ( + + ) : null} + + + + Email + Status + +
+
Actions
+
+
+
+
+ + {slackusers.map((user) => ( + + {user.email} + + {user.status === "live" ? "Active" : "Inactive"} + + + u.email) + .includes(user.email)} + setPopup={setPopup} + mutate={mutate} + /> + + + ))} + +
+ + ); +}; + +export default SlackUserTable; diff --git a/web/src/components/admin/users/UserStatusButtons.tsx b/web/src/components/admin/users/UserStatusButtons.tsx new file mode 100644 index 000000000..4963c3b84 --- /dev/null +++ b/web/src/components/admin/users/UserStatusButtons.tsx @@ -0,0 +1,250 @@ +import { + type User, + UserStatus, + UserRole, + USER_ROLE_LABELS, + INVALID_ROLE_HOVER_TEXT, +} from "@/lib/types"; +import CenteredPageSelector from "./CenteredPageSelector"; +import { type PageSelectorProps } from "@/components/PageSelector"; +import { HidableSection } from "@/app/admin/assistants/HidableSection"; +import { PopupSpec } from "@/components/admin/connectors/Popup"; +import userMutationFetcher from "@/lib/admin/users/userMutationFetcher"; +import useSWRMutation from "swr/mutation"; +import { + Table, + TableHead, + TableRow, + TableBody, + TableCell, +} from "@/components/ui/table"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal"; +import { useState } from "react"; +import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; +import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal"; +import { TableHeader } from "@/components/ui/table"; + +export const InviteUserButton = ({ + user, + invited, + setPopup, + mutate, +}: { + user: User; + invited: boolean; + setPopup: (spec: PopupSpec) => void; + mutate: () => void; +}) => { + const { trigger: inviteTrigger, isMutating: isInviting } = useSWRMutation( + "/api/manage/admin/users", + async (url, { arg }: { arg: { emails: string[] } }) => { + const response = await fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(arg), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json(); + }, + { + onSuccess: () => { + setShowInviteModal(false); + mutate(); + setPopup({ + message: "User invited successfully!", + type: "success", + }); + }, + onError: (errorMsg) => + setPopup({ + message: `Unable to invite user - ${errorMsg}`, + type: "error", + }), + } + ); + + const { trigger: uninviteTrigger, isMutating: isUninviting } = useSWRMutation( + "/api/manage/admin/remove-invited-user", + async (url, { arg }: { arg: { user_email: string } }) => { + const response = await fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(arg), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json(); + }, + { + onSuccess: () => { + setShowInviteModal(false); + mutate(); + setPopup({ + message: "User uninvited successfully!", + type: "success", + }); + }, + onError: (errorMsg) => + setPopup({ + message: `Unable to uninvite user - ${errorMsg}`, + type: "error", + }), + } + ); + + const [showInviteModal, setShowInviteModal] = useState(false); + + const handleConfirm = () => { + if (invited) { + uninviteTrigger({ user_email: user.email }); + } else { + inviteTrigger({ emails: [user.email] }); + } + }; + + const isMutating = isInviting || isUninviting; + + return ( + <> + {showInviteModal && ( + setShowInviteModal(false)} + onConfirm={handleConfirm} + /> + )} + + + + ); +}; + +export const UserRoleDropdown = ({ + user, + onSuccess, + onError, +}: { + user: User; + onSuccess: () => void; + onError: (message: string) => void; +}) => { + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [pendingRole, setPendingRole] = useState(null); + + const { trigger: setUserRole, isMutating: isSettingRole } = useSWRMutation( + "/api/manage/set-user-role", + userMutationFetcher, + { onSuccess, onError } + ); + const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled(); + + const handleChange = (value: string) => { + if (value === user.role) return; + if (user.role === UserRole.CURATOR) { + setShowConfirmModal(true); + setPendingRole(value); + } else { + setUserRole({ + user_email: user.email, + new_role: value, + }); + } + }; + + const handleConfirm = () => { + if (pendingRole) { + setUserRole({ + user_email: user.email, + new_role: pendingRole, + }); + } + setShowConfirmModal(false); + setPendingRole(null); + }; + + return ( + <> + + {showConfirmModal && ( + setShowConfirmModal(false)} + onConfirm={handleConfirm} + /> + )} + + ); +}; diff --git a/web/src/components/admin/users/buttons/DeactivaterButton.tsx b/web/src/components/admin/users/buttons/DeactivaterButton.tsx new file mode 100644 index 000000000..d1e0994db --- /dev/null +++ b/web/src/components/admin/users/buttons/DeactivaterButton.tsx @@ -0,0 +1,73 @@ +import { + type User, + UserStatus, + UserRole, + USER_ROLE_LABELS, + INVALID_ROLE_HOVER_TEXT, +} from "@/lib/types"; +import { type PageSelectorProps } from "@/components/PageSelector"; +import { HidableSection } from "@/app/admin/assistants/HidableSection"; +import { PopupSpec } from "@/components/admin/connectors/Popup"; +import userMutationFetcher from "@/lib/admin/users/userMutationFetcher"; +import useSWRMutation from "swr/mutation"; +import { + Table, + TableHead, + TableRow, + TableBody, + TableCell, +} from "@/components/ui/table"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal"; +import { useState } from "react"; +import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; +import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal"; +import { TableHeader } from "@/components/ui/table"; + +export const DeactivaterButton = ({ + user, + deactivate, + setPopup, + mutate, +}: { + user: User; + deactivate: boolean; + setPopup: (spec: PopupSpec) => void; + mutate: () => void; +}) => { + const { trigger, isMutating } = useSWRMutation( + deactivate + ? "/api/manage/admin/deactivate-user" + : "/api/manage/admin/activate-user", + userMutationFetcher, + { + onSuccess: () => { + mutate(); + setPopup({ + message: `User ${deactivate ? "deactivated" : "activated"}!`, + type: "success", + }); + }, + onError: (errorMsg) => + setPopup({ message: errorMsg.message, type: "error" }), + } + ); + return ( + + ); +}; diff --git a/web/src/components/admin/users/buttons/DeleteUserButton.tsx b/web/src/components/admin/users/buttons/DeleteUserButton.tsx new file mode 100644 index 000000000..7c3cd7ef7 --- /dev/null +++ b/web/src/components/admin/users/buttons/DeleteUserButton.tsx @@ -0,0 +1,61 @@ +import { type User } from "@/lib/types"; +import { PopupSpec } from "@/components/admin/connectors/Popup"; +import userMutationFetcher from "@/lib/admin/users/userMutationFetcher"; +import useSWRMutation from "swr/mutation"; +import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal"; + +export const DeleteUserButton = ({ + user, + setPopup, + mutate, +}: { + user: User; + setPopup: (spec: PopupSpec) => void; + mutate: () => void; +}) => { + const { trigger, isMutating } = useSWRMutation( + "/api/manage/admin/delete-user", + userMutationFetcher, + { + onSuccess: () => { + mutate(); + setPopup({ + message: "User deleted successfully!", + type: "success", + }); + }, + onError: (errorMsg) => + setPopup({ + message: `Unable to delete user - ${errorMsg}`, + type: "error", + }), + } + ); + + const [showDeleteModal, setShowDeleteModal] = useState(false); + return ( + <> + {showDeleteModal && ( + setShowDeleteModal(false)} + onSubmit={() => trigger({ user_email: user.email, method: "DELETE" })} + additionalDetails="All data associated with this user will be deleted (including personas, tools and chat sessions)." + /> + )} + + + + ); +}; diff --git a/web/src/components/admin/users/buttons/InviteUserButton.tsx b/web/src/components/admin/users/buttons/InviteUserButton.tsx new file mode 100644 index 000000000..e456cc734 --- /dev/null +++ b/web/src/components/admin/users/buttons/InviteUserButton.tsx @@ -0,0 +1,119 @@ +import { type User } from "@/lib/types"; + +import { PopupSpec } from "@/components/admin/connectors/Popup"; +import useSWRMutation from "swr/mutation"; +import { Button } from "@/components/ui/button"; +import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal"; +import { useState } from "react"; + +export const InviteUserButton = ({ + user, + invited, + setPopup, + mutate, +}: { + user: User; + invited: boolean; + setPopup: (spec: PopupSpec) => void; + mutate: () => void; +}) => { + const { trigger: inviteTrigger, isMutating: isInviting } = useSWRMutation( + "/api/manage/admin/users", + async (url, { arg }: { arg: { emails: string[] } }) => { + const response = await fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(arg), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json(); + }, + { + onSuccess: () => { + setShowInviteModal(false); + mutate(); + setPopup({ + message: "User invited successfully!", + type: "success", + }); + }, + onError: (errorMsg) => + setPopup({ + message: `Unable to invite user - ${errorMsg}`, + type: "error", + }), + } + ); + + const { trigger: uninviteTrigger, isMutating: isUninviting } = useSWRMutation( + "/api/manage/admin/remove-invited-user", + async (url, { arg }: { arg: { user_email: string } }) => { + const response = await fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(arg), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json(); + }, + { + onSuccess: () => { + setShowInviteModal(false); + mutate(); + setPopup({ + message: "User uninvited successfully!", + type: "success", + }); + }, + onError: (errorMsg) => + setPopup({ + message: `Unable to uninvite user - ${errorMsg}`, + type: "error", + }), + } + ); + + const [showInviteModal, setShowInviteModal] = useState(false); + + const handleConfirm = () => { + if (invited) { + uninviteTrigger({ user_email: user.email }); + } else { + inviteTrigger({ emails: [user.email] }); + } + }; + + const isMutating = isInviting || isUninviting; + + return ( + <> + {showInviteModal && ( + setShowInviteModal(false)} + onConfirm={handleConfirm} + /> + )} + + + + ); +}; diff --git a/web/src/components/admin/users/buttons/UserRoleDropdown.tsx b/web/src/components/admin/users/buttons/UserRoleDropdown.tsx new file mode 100644 index 000000000..3f7ac121a --- /dev/null +++ b/web/src/components/admin/users/buttons/UserRoleDropdown.tsx @@ -0,0 +1,123 @@ +import { + type User, + UserRole, + USER_ROLE_LABELS, + INVALID_ROLE_HOVER_TEXT, +} from "@/lib/types"; +import userMutationFetcher from "@/lib/admin/users/userMutationFetcher"; +import useSWRMutation from "swr/mutation"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal"; +import { useState } from "react"; +import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; + +export const UserRoleDropdown = ({ + user, + onSuccess, + onError, +}: { + user: User; + onSuccess: () => void; + onError: (message: string) => void; +}) => { + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [pendingRole, setPendingRole] = useState(null); + + const { trigger: setUserRole, isMutating: isSettingRole } = useSWRMutation( + "/api/manage/set-user-role", + userMutationFetcher, + { onSuccess, onError } + ); + const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled(); + + const handleChange = (value: string) => { + if (value === user.role) return; + if (user.role === UserRole.CURATOR) { + setShowConfirmModal(true); + setPendingRole(value); + } else { + setUserRole({ + user_email: user.email, + new_role: value, + }); + } + }; + + const handleConfirm = () => { + if (pendingRole) { + setUserRole({ + user_email: user.email, + new_role: pendingRole, + }); + } + setShowConfirmModal(false); + setPendingRole(null); + }; + + return ( + <> + + {showConfirmModal && ( + setShowConfirmModal(false)} + onConfirm={handleConfirm} + /> + )} + + ); +}; diff --git a/web/src/lib/users/interfaces.ts b/web/src/lib/users/interfaces.ts index 1bcd00ae2..c2963521f 100644 --- a/web/src/lib/users/interfaces.ts +++ b/web/src/lib/users/interfaces.ts @@ -3,6 +3,8 @@ import { User } from "../types"; export interface UsersResponse { accepted: User[]; invited: User[]; + slack_users: User[]; accepted_pages: number; invited_pages: number; + slack_users_pages: number; } diff --git a/web/tests/e2e/admin_users.spec.ts b/web/tests/e2e/admin_users.spec.ts index 6ec4c803c..2fc318884 100644 --- a/web/tests/e2e/admin_users.spec.ts +++ b/web/tests/e2e/admin_users.spec.ts @@ -9,11 +9,5 @@ test( // Test simple loading await page.goto("http://localhost:3000/admin/users"); await expect(page.locator("h1.text-3xl")).toHaveText("Manage Users"); - await expect(page.locator("div.font-bold").nth(0)).toHaveText( - "Invited Users" - ); - await expect(page.locator("div.font-bold").nth(1)).toHaveText( - "Current Users" - ); } );