base role setting fix (#3381)

* base role setting fix

* update user tables

* finalize

* minor cleanup

* fix chromatic
This commit is contained in:
pablonyx 2024-12-11 10:09:47 -08:00 committed by GitHub
parent c667d28e7a
commit d95959fb41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 939 additions and 412 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<div className="text-sm">
No invited users. Anyone can sign up with a valid email address. To
restrict access you can:
<div className="flex flex-wrap ml-2 mt-1">
(1) Invite users above. Once a user has been invited, only emails that
have explicitly been invited will be able to sign-up.
</div>
<div className="mt-1 ml-2">
(2) Set the{" "}
<b className="font-mono w-fit h-fit">VALID_EMAIL_DOMAINS</b>{" "}
environment variable to a comma separated list of email domains. This
will restrict access to users with email addresses from these domains.
</div>
</div>
);
}
return (
<div className="text-sm">
No invited users. Anyone with an email address with any of the following
domains can sign up: <i>{validDomains.join(", ")}</i>.
<div className="mt-2">
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.
</div>
</div>
);
};
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<UsersResponse>(
`/api/manage/users?q=${encodeURI(q)}&accepted_page=${
const [slackUsersPage, setSlackUsersPage] = useState(1);
const [usersData, setUsersData] = useState<UsersResponse | undefined>(
undefined
);
const [domainsData, setDomainsData] = useState<string[] | undefined>(
undefined
);
const { data, error, mutate } = useSWR<UsersResponse>(
`/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<string[]>("/api/manage/admin/valid-domains", errorHandlingFetcher);
if (isLoading || isLoadingDomains) {
const { data: validDomains, error: domainsError } = useSWR<string[]>(
"/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 <LoadingAnimation text="Loading" />;
}
if (error || !data) {
if (error) {
return (
<ErrorCallout
errorTitle="Error loading users"
@ -86,7 +79,7 @@ const UsersTables = ({
);
}
if (domainsError || !validDomains) {
if (domainsError) {
return (
<ErrorCallout
errorTitle="Error loading valid domains"
@ -95,45 +88,94 @@ const UsersTables = ({
);
}
const { accepted, invited, accepted_pages, invited_pages } = data;
const {
accepted,
invited,
accepted_pages,
invited_pages,
slack_users,
slack_users_pages,
} = activeData;
// remove users that are already accepted
const finalInvited = invited.filter(
(user) => !accepted.map((u) => u.email).includes(user.email)
(user) => !accepted.some((u) => u.email === user.email)
);
return (
<>
<HidableSection sectionTitle="Invited Users">
{invited.length > 0 ? (
finalInvited.length > 0 ? (
<InvitedUserTable
users={finalInvited}
setPopup={setPopup}
currentPage={invitedPage}
onPageChange={setInvitedPage}
totalPages={invited_pages}
mutate={mutate}
/>
) : (
<div className="text-sm">
To invite additional teammates, use the <b>Invite Users</b> button
above!
</div>
)
) : (
<ValidDomainsDisplay validDomains={validDomains} />
)}
</HidableSection>
<SignedUpUserTable
users={accepted}
setPopup={setPopup}
currentPage={acceptedPage}
onPageChange={setAcceptedPage}
totalPages={accepted_pages}
mutate={mutate}
/>
</>
<Tabs defaultValue="invited">
<TabsList>
<TabsTrigger value="invited">Invited Users</TabsTrigger>
<TabsTrigger value="current">Current Users</TabsTrigger>
<TabsTrigger value="danswerbot">DanswerBot Users</TabsTrigger>
</TabsList>
<TabsContent value="invited">
<Card>
<CardHeader>
<CardTitle>Invited Users</CardTitle>
</CardHeader>
<CardContent>
{finalInvited.length > 0 ? (
<InvitedUserTable
users={finalInvited}
setPopup={setPopup}
currentPage={invitedPage}
onPageChange={setInvitedPage}
totalPages={invited_pages}
mutate={mutate}
/>
) : (
<p>Users that have been invited will show up here</p>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="current">
<Card>
<CardHeader>
<CardTitle>Current Users</CardTitle>
</CardHeader>
<CardContent>
{accepted.length > 0 ? (
<SignedUpUserTable
users={accepted}
setPopup={setPopup}
currentPage={acceptedPage}
onPageChange={setAcceptedPage}
totalPages={accepted_pages}
mutate={mutate}
/>
) : (
<p>Users that have an account will show up here</p>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="danswerbot">
<Card>
<CardHeader>
<CardTitle>DanswerBot Users</CardTitle>
</CardHeader>
<CardContent>
{slack_users.length > 0 ? (
<SlackUserTable
setPopup={setPopup}
currentPage={slackUsersPage}
onPageChange={setSlackUsersPage}
totalPages={slack_users_pages}
invitedUsers={finalInvited}
slackusers={slack_users}
mutate={mutate}
/>
) : (
<p>Slack-only users will show up here</p>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
};
@ -215,6 +257,7 @@ const Page = () => {
return (
<div className="mx-auto container">
<AdminPageTitle title="Manage Users" icon={<UsersIcon size={32} />} />
<SearchableTables />
</div>
);

View File

@ -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<User>;
@ -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 (
<Button onClick={() => trigger({ user_email: user.email })}>
Uninvite User
</Button>
);
};
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 (
<>
<Table className="overflow-visible">
@ -83,10 +47,11 @@ const InvitedUserTable = ({
<TableCell>{user.email}</TableCell>
<TableCell>
<div className="flex justify-end">
<RemoveUserButton
<InviteUserButton
user={user}
onSuccess={onRemovalSuccess}
onError={onRemovalError}
invited={true}
setPopup={setPopup}
mutate={mutate}
/>
</div>
</TableCell>

View File

@ -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<User>;
@ -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<string | null>(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 (
<>
<Select
value={user.role}
onValueChange={handleChange}
disabled={isSettingRole}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(USER_ROLE_LABELS) as [UserRole, string][]).map(
([role, label]) => {
// Dont want to ever show external permissioned users because it's scary
if (role === UserRole.EXT_PERM_USER) return null;
// Only want to show limited users if paid enterprise features are enabled
// Also, dont want to show these other roles in general
const isNotVisibleRole =
(!isPaidEnterpriseFeaturesEnabled &&
role === UserRole.GLOBAL_CURATOR) ||
role === UserRole.CURATOR ||
role === UserRole.LIMITED ||
role === UserRole.SLACK_USER;
// Always show the current role
const isCurrentRole = user.role === role;
return isNotVisibleRole && !isCurrentRole ? null : (
<SelectItem
key={role}
value={role}
title={INVALID_ROLE_HOVER_TEXT[role] ?? ""}
data-tooltip-delay="0"
>
{label}
</SelectItem>
);
}
)}
</SelectContent>
</Select>
{showConfirmModal && (
<GenericConfirmModal
title="Change Curator Role"
message={`Warning: Switching roles from Curator to ${
USER_ROLE_LABELS[pendingRole as UserRole] ??
USER_ROLE_LABELS[user.role]
} will remove their status as individual curators from all groups.`}
confirmText={`Switch Role to ${
USER_ROLE_LABELS[pendingRole as UserRole] ??
USER_ROLE_LABELS[user.role]
}`}
onClose={() => 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 (
<Button
className="w-min"
onClick={() => trigger({ user_email: user.email })}
disabled={isMutating}
size="sm"
>
{deactivate ? "Deactivate" : "Activate"}
</Button>
);
};
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 && (
<DeleteEntityModal
entityType="user"
entityName={user.email}
onClose={() => 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)."
/>
)}
<Button
className="w-min"
onClick={() => setShowDeleteModal(true)}
disabled={isMutating}
size="sm"
variant="destructive"
>
Delete
</Button>
</>
);
};
const SignedUpUserTable = ({
users,
setPopup,
@ -258,68 +41,66 @@ const SignedUpUserTable = ({
handlePopup(`Unable to update user role - ${errorMsg}`, "error");
return (
<HidableSection sectionTitle="Current Users">
<>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead className="text-center">Role</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead>
<div className="flex">
<div className="ml-auto">Actions</div>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users
// Dont want to show external permissioned users because it's scary
.filter((user) => user.role !== UserRole.EXT_PERM_USER)
.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.email}</TableCell>
<TableCell className="w-40 ">
<UserRoleDropdown
<>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
<Table className="overflow-visible">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead className="text-center">Role</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead>
<div className="flex">
<div className="ml-auto">Actions</div>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users
// Dont want to show external permissioned users because it's scary
.filter((user) => user.role !== UserRole.EXT_PERM_USER)
.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.email}</TableCell>
<TableCell className="w-40 ">
<UserRoleDropdown
user={user}
onSuccess={onRoleChangeSuccess}
onError={onRoleChangeError}
/>
</TableCell>
<TableCell className="text-center">
<i>{user.status === "live" ? "Active" : "Inactive"}</i>
</TableCell>
<TableCell>
<div className="flex justify-end gap-x-2">
<DeactivaterButton
user={user}
onSuccess={onRoleChangeSuccess}
onError={onRoleChangeError}
deactivate={user.status === UserStatus.live}
setPopup={setPopup}
mutate={mutate}
/>
</TableCell>
<TableCell className="text-center">
<i>{user.status === "live" ? "Active" : "Inactive"}</i>
</TableCell>
<TableCell>
<div className="flex justify-end gap-x-2">
<DeactivaterButton
{user.status == UserStatus.deactivated && (
<DeleteUserButton
user={user}
deactivate={user.status === UserStatus.live}
setPopup={setPopup}
mutate={mutate}
/>
{user.status == UserStatus.deactivated && (
<DeleteUserButton
user={user}
setPopup={setPopup}
mutate={mutate}
/>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
</HidableSection>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
};

View File

@ -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<SlackUserTableProps & PageSelectorProps> = ({
invitedUsers,
slackusers,
mutate,
currentPage,
totalPages,
onPageChange,
setPopup,
}) => {
return (
<>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead>
<div className="flex">
<div className="ml-auto">Actions</div>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slackusers.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.email}</TableCell>
<TableCell className="text-center">
<i>{user.status === "live" ? "Active" : "Inactive"}</i>
</TableCell>
<TableCell className="flex justify-end">
<InviteUserButton
user={user}
invited={invitedUsers
.map((u) => u.email)
.includes(user.email)}
setPopup={setPopup}
mutate={mutate}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
};
export default SlackUserTable;

View File

@ -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 && (
<GenericConfirmModal
title={`${invited ? "Uninvite" : "Invite"} User`}
message={`Are you sure you want to ${
invited ? "uninvite" : "invite"
} ${user.email}?`}
onClose={() => setShowInviteModal(false)}
onConfirm={handleConfirm}
/>
)}
<Button
className="w-min"
onClick={() => setShowInviteModal(true)}
disabled={isMutating}
size="sm"
>
{invited ? "Uninvite" : "Invite"}
</Button>
</>
);
};
export const UserRoleDropdown = ({
user,
onSuccess,
onError,
}: {
user: User;
onSuccess: () => void;
onError: (message: string) => void;
}) => {
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [pendingRole, setPendingRole] = useState<string | null>(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 (
<>
<Select
value={user.role}
onValueChange={handleChange}
disabled={isSettingRole}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(USER_ROLE_LABELS) as [UserRole, string][]).map(
([role, label]) => {
// Dont want to ever show external permissioned users because it's scary
if (role === UserRole.EXT_PERM_USER) return null;
// Only want to show limited users if paid enterprise features are enabled
// Also, dont want to show these other roles in general
const isNotVisibleRole =
(!isPaidEnterpriseFeaturesEnabled &&
role === UserRole.GLOBAL_CURATOR) ||
role === UserRole.CURATOR ||
role === UserRole.LIMITED ||
role === UserRole.SLACK_USER;
// Always show the current role
const isCurrentRole = user.role === role;
return isNotVisibleRole && !isCurrentRole ? null : (
<SelectItem
key={role}
value={role}
title={INVALID_ROLE_HOVER_TEXT[role] ?? ""}
data-tooltip-delay="0"
>
{label}
</SelectItem>
);
}
)}
</SelectContent>
</Select>
{showConfirmModal && (
<GenericConfirmModal
title="Change Curator Role"
message={`Warning: Switching roles from Curator to ${
USER_ROLE_LABELS[pendingRole as UserRole] ??
USER_ROLE_LABELS[user.role]
} will remove their status as individual curators from all groups.`}
confirmText={`Switch Role to ${
USER_ROLE_LABELS[pendingRole as UserRole] ??
USER_ROLE_LABELS[user.role]
}`}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirm}
/>
)}
</>
);
};

View File

@ -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 (
<Button
className="w-min"
onClick={() => trigger({ user_email: user.email })}
disabled={isMutating}
size="sm"
>
{deactivate ? "Deactivate" : "Activate"}
</Button>
);
};

View File

@ -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 && (
<DeleteEntityModal
entityType="user"
entityName={user.email}
onClose={() => 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)."
/>
)}
<Button
className="w-min"
onClick={() => setShowDeleteModal(true)}
disabled={isMutating}
size="sm"
variant="destructive"
>
Delete
</Button>
</>
);
};

View File

@ -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 && (
<GenericConfirmModal
title={`${invited ? "Uninvite" : "Invite"} User`}
message={`Are you sure you want to ${
invited ? "uninvite" : "invite"
} ${user.email}?`}
onClose={() => setShowInviteModal(false)}
onConfirm={handleConfirm}
/>
)}
<Button
className="w-min"
onClick={() => setShowInviteModal(true)}
disabled={isMutating}
size="sm"
>
{invited ? "Uninvite" : "Invite"}
</Button>
</>
);
};

View File

@ -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<string | null>(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 (
<>
<Select
value={user.role}
onValueChange={handleChange}
disabled={isSettingRole}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(USER_ROLE_LABELS) as [UserRole, string][]).map(
([role, label]) => {
// Dont want to ever show external permissioned users because it's scary
if (role === UserRole.EXT_PERM_USER) return null;
// Only want to show limited users if paid enterprise features are enabled
// Also, dont want to show these other roles in general
const isNotVisibleRole =
(!isPaidEnterpriseFeaturesEnabled &&
role === UserRole.GLOBAL_CURATOR) ||
role === UserRole.CURATOR ||
role === UserRole.LIMITED ||
role === UserRole.SLACK_USER;
// Always show the current role
const isCurrentRole = user.role === role;
return isNotVisibleRole && !isCurrentRole ? null : (
<SelectItem
key={role}
value={role}
title={INVALID_ROLE_HOVER_TEXT[role] ?? ""}
data-tooltip-delay="0"
>
{label}
</SelectItem>
);
}
)}
</SelectContent>
</Select>
{showConfirmModal && (
<GenericConfirmModal
title="Change Curator Role"
message={`Warning: Switching roles from Curator to ${
USER_ROLE_LABELS[pendingRole as UserRole] ??
USER_ROLE_LABELS[user.role]
} will remove their status as individual curators from all groups.`}
confirmText={`Switch Role to ${
USER_ROLE_LABELS[pendingRole as UserRole] ??
USER_ROLE_LABELS[user.role]
}`}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirm}
/>
)}
</>
);
};

View File

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

View File

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