User Management: Invite, Deactivate, Search, & Paginate (#1631)

This commit is contained in:
Liam Norris 2024-06-18 11:28:47 -07:00 committed by GitHub
parent 4e15ba78d5
commit 58b5e25c97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 737 additions and 130 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@
.idea
/deployment/data/nginx/app.conf
.vscode/launch.json
*.sw?

View File

@ -72,6 +72,10 @@ For convenience here's a command for it:
python -m venv .venv
source .venv/bin/activate
```
--> Note that this virtual environment MUST NOT be set up WITHIN the danswer
directory
_For Windows, activate the virtual environment using Command Prompt:_
```bash
.venv\Scripts\activate

View File

@ -0,0 +1,21 @@
from typing import cast
from danswer.dynamic_configs.factory import get_dynamic_config_store
from danswer.dynamic_configs.interface import ConfigNotFoundError
from danswer.dynamic_configs.interface import JSON_ro
USER_STORE_KEY = "INVITED_USERS"
def get_invited_users() -> list[str]:
try:
store = get_dynamic_config_store()
return cast(list, store.load(USER_STORE_KEY))
except ConfigNotFoundError:
return list()
def write_invited_users(emails: list[str]) -> int:
store = get_dynamic_config_store()
store.store(USER_STORE_KEY, cast(JSON_ro, emails))
return len(emails)

View File

@ -9,6 +9,12 @@ class UserRole(str, Enum):
ADMIN = "admin"
class UserStatus(str, Enum):
LIVE = "live"
INVITED = "invited"
DEACTIVATED = "deactivated"
class UserRead(schemas.BaseUser[uuid.UUID]):
role: UserRole

View File

@ -1,4 +1,3 @@
import os
import smtplib
import uuid
from collections.abc import AsyncGenerator
@ -27,6 +26,7 @@ from fastapi_users.openapi import OpenAPIResponseType
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
from sqlalchemy.orm import Session
from danswer.auth.invited_users import get_invited_users
from danswer.auth.schemas import UserCreate
from danswer.auth.schemas import UserRole
from danswer.configs.app_configs import AUTH_TYPE
@ -59,9 +59,6 @@ from danswer.utils.variable_functionality import fetch_versioned_implementation
logger = setup_logger()
USER_WHITELIST_FILE = "/home/danswer_whitelist.txt"
_user_whitelist: list[str] | None = None
def verify_auth_setting() -> None:
if AUTH_TYPE not in [AuthType.DISABLED, AuthType.BASIC, AuthType.GOOGLE_OAUTH]:
@ -92,20 +89,8 @@ def user_needs_to_be_verified() -> bool:
return AUTH_TYPE != AuthType.BASIC or REQUIRE_EMAIL_VERIFICATION
def get_user_whitelist() -> list[str]:
global _user_whitelist
if _user_whitelist is None:
if os.path.exists(USER_WHITELIST_FILE):
with open(USER_WHITELIST_FILE, "r") as file:
_user_whitelist = [line.strip() for line in file]
else:
_user_whitelist = []
return _user_whitelist
def verify_email_in_whitelist(email: str) -> None:
whitelist = get_user_whitelist()
whitelist = get_invited_users()
if (whitelist and email not in whitelist) or not email:
raise PermissionError("User not on allowed user whitelist")

View File

@ -1,15 +1,18 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.schema import Column
from danswer.db.models import User
def list_users(db_session: Session) -> Sequence[User]:
def list_users(db_session: Session, q: str = "") -> Sequence[User]:
"""List all users. No pagination as of now, as the # of users
is assumed to be relatively small (<< 1 million)"""
return db_session.scalars(select(User)).unique().all()
query = db_session.query(User)
if q:
query = query.filter(Column("email").ilike("%{}%".format(q)))
return query.all()
def get_user_by_email(email: str, db_session: Session) -> User | None:

View File

@ -10,6 +10,8 @@ from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from danswer.auth.invited_users import get_invited_users
from danswer.auth.invited_users import write_invited_users
from danswer.auth.users import current_admin_user
from danswer.configs.app_configs import GENERATIVE_MODEL_ACCESS_CHECK_FREQ
from danswer.configs.app_configs import TOKEN_BUDGET_GLOBALLY_ENABLED
@ -26,6 +28,7 @@ from danswer.db.feedback import update_document_boost
from danswer.db.feedback import update_document_hidden
from danswer.db.index_attempt import cancel_indexing_attempts_for_connector
from danswer.db.models import User
from danswer.db.users import get_user_by_email
from danswer.document_index.document_index_utils import get_both_index_names
from danswer.document_index.factory import get_default_document_index
from danswer.dynamic_configs.factory import get_dynamic_config_store
@ -37,6 +40,7 @@ from danswer.server.documents.models import ConnectorCredentialPairIdentifier
from danswer.server.manage.models import BoostDoc
from danswer.server.manage.models import BoostUpdateRequest
from danswer.server.manage.models import HiddenUpdateRequest
from danswer.server.manage.models import UserByEmail
from danswer.utils.logger import setup_logger
router = APIRouter(prefix="/manage")
@ -231,3 +235,66 @@ def update_token_budget_settings(
# Store the settings in the dynamic config store
get_dynamic_config_store().store(TOKEN_BUDGET_SETTINGS, settings_json)
return {"message": "Token budget settings updated successfully."}
@router.put("/admin/users")
def bulk_invite_users(
emails: list[str] = Body(..., embed=True),
_: User | None = Depends(current_admin_user),
) -> int:
all_emails = list(set(emails) | set(get_invited_users()))
return write_invited_users(all_emails)
@router.patch("/admin/remove-invited-user")
def remove_invited_user(
user_email: UserByEmail,
_: User | None = Depends(current_admin_user),
) -> int:
user_emails = get_invited_users()
remaining_users = [user for user in user_emails if user != user_email.user_email]
return write_invited_users(remaining_users)
@router.patch("/admin/deactivate-user")
def deactivate_user(
user_email: UserByEmail,
current_user: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
if current_user.email == user_email.user_email:
raise HTTPException(status_code=400, detail="You cannot deactivate yourself")
user_to_deactivate = get_user_by_email(
email=user_email.user_email, db_session=db_session
)
if not user_to_deactivate:
raise HTTPException(status_code=404, detail="User not found")
if user_to_deactivate.is_active is False:
logger.warning("{} is already deactivated".format(user_to_deactivate.email))
user_to_deactivate.is_active = False
db_session.add(user_to_deactivate)
db_session.commit()
@router.patch("/admin/activate-user")
def activate_user(
user_email: UserByEmail,
_: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
user_to_activate = get_user_by_email(
email=user_email.user_email, db_session=db_session
)
if not user_to_activate:
raise HTTPException(status_code=404, detail="User not found")
if user_to_activate.is_active is True:
logger.warning("{} is already activated".format(user_to_activate.email))
user_to_activate.is_active = True
db_session.add(user_to_activate)
db_session.commit()

View File

@ -14,6 +14,8 @@ from danswer.db.models import SlackBotConfig as SlackBotConfigModel
from danswer.db.models import SlackBotResponseType
from danswer.indexing.models import EmbeddingModelDetail
from danswer.server.features.persona.models import PersonaSnapshot
from danswer.server.models import FullUserSnapshot
from danswer.server.models import InvitedUserSnapshot
if TYPE_CHECKING:
from danswer.db.models import User as UserModel
@ -152,3 +154,10 @@ class SlackBotConfig(BaseModel):
class FullModelVersionResponse(BaseModel):
current_model: EmbeddingModelDetail
secondary_model: EmbeddingModelDetail | None
class AllUsersResponse(BaseModel):
accepted: list[FullUserSnapshot]
invited: list[InvitedUserSnapshot]
accepted_pages: int
invited_pages: int

View File

@ -1,3 +1,5 @@
import re
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
@ -6,10 +8,11 @@ from pydantic import BaseModel
from sqlalchemy import update
from sqlalchemy.orm import Session
from danswer.auth.invited_users import get_invited_users
from danswer.auth.noauth_user import fetch_no_auth_user
from danswer.auth.noauth_user import set_no_auth_user_preferences
from danswer.auth.schemas import UserRead
from danswer.auth.schemas import UserRole
from danswer.auth.schemas import UserStatus
from danswer.auth.users import current_admin_user
from danswer.auth.users import current_user
from danswer.auth.users import optional_user
@ -20,13 +23,18 @@ from danswer.db.models import User
from danswer.db.users import get_user_by_email
from danswer.db.users import list_users
from danswer.dynamic_configs.factory import get_dynamic_config_store
from danswer.server.manage.models import AllUsersResponse
from danswer.server.manage.models import UserByEmail
from danswer.server.manage.models import UserInfo
from danswer.server.manage.models import UserRoleResponse
from danswer.server.models import FullUserSnapshot
from danswer.server.models import InvitedUserSnapshot
from danswer.server.models import MinimalUserSnapshot
router = APIRouter()
USERS_PAGE_SIZE = 10
@router.patch("/manage/promote-user-to-admin")
def promote_admin(
@ -69,11 +77,41 @@ async def demote_admin(
@router.get("/manage/users")
def list_all_users(
q: str,
accepted_page: int,
invited_page: int,
_: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[UserRead]:
users = list_users(db_session)
return [UserRead.from_orm(user) for user in users]
) -> AllUsersResponse:
users = list_users(db_session, q=q)
accepted_emails = {user.email for user in users}
invited_emails = get_invited_users()
if q:
invited_emails = [
email for email in invited_emails if re.search(r"{}".format(q), email, re.I)
]
accepted_count = len(accepted_emails)
invited_count = len(invited_emails)
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
status=UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED,
)
for user in users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
invited=[
InvitedUserSnapshot(email=email)
for email in invited_emails
if email not in accepted_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,
)
"""Endpoints for all"""

View File

@ -6,6 +6,9 @@ from uuid import UUID
from pydantic import BaseModel
from pydantic.generics import GenericModel
from danswer.auth.schemas import UserRole
from danswer.auth.schemas import UserStatus
DataT = TypeVar("DataT")
@ -29,5 +32,16 @@ class MinimalUserSnapshot(BaseModel):
email: str
class FullUserSnapshot(BaseModel):
id: UUID
email: str
role: UserRole
status: UserStatus
class InvitedUserSnapshot(BaseModel):
email: str
class DisplayPriorityRequest(BaseModel):
display_priority_map: dict[int, int]

View File

@ -1,4 +1,11 @@
"use client";
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 Link from "next/link";
import { Modal } from "@/components/Modal";
import {
Table,
@ -8,30 +15,46 @@ import {
TableBody,
TableCell,
Button,
Text,
} from "@tremor/react";
import { LoadingAnimation } from "@/components/Loading";
import { AdminPageTitle } from "@/components/admin/Title";
import { usePopup } from "@/components/admin/connectors/Popup";
import { usePopup, PopupSpec } from "@/components/admin/connectors/Popup";
import { UsersIcon } from "@/components/icons/icons";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { User } from "@/lib/types";
import { type User, UserStatus } from "@/lib/types";
import useSWR, { mutate } from "swr";
import useSWRMutation from "swr/mutation";
import { ErrorCallout } from "@/components/ErrorCallout";
import { HidableSection } from "@/app/admin/assistants/HidableSection";
import BulkAdd from "@/components/admin/users/BulkAdd";
const UsersTable = () => {
const { popup, setPopup } = usePopup();
interface UsersResponse {
accepted: User[];
invited: User[];
accepted_pages: number;
invited_pages: number;
}
const {
data: users,
isLoading,
error,
} = useSWR<User[]>("/api/manage/users", errorHandlingFetcher);
const UsersTables = ({
q,
setPopup,
}: {
q: string;
setPopup: (spec: PopupSpec) => void;
}) => {
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=${acceptedPage - 1}&invited_page=${invitedPage - 1}`,
errorHandlingFetcher
);
if (isLoading) {
return <LoadingAnimation text="Loading" />;
}
if (error || !users) {
if (error || !data) {
return (
<ErrorCallout
errorTitle="Error loading users"
@ -40,113 +63,99 @@ const UsersTable = () => {
);
}
const { accepted, invited, accepted_pages, invited_pages } = data;
return (
<>
<InvitedUserTable
users={invited}
setPopup={setPopup}
currentPage={invitedPage}
onPageChange={setInvitedPage}
totalPages={invited_pages}
mutate={mutate}
/>
<SignedUpUserTable
users={accepted}
setPopup={setPopup}
currentPage={acceptedPage}
onPageChange={setAcceptedPage}
totalPages={accepted_pages}
mutate={mutate}
/>
</>
);
};
const SearchableTables = () => {
const { popup, setPopup } = usePopup();
const [query, setQuery] = useState("");
const [q, setQ] = useState("");
return (
<div>
{popup}
<Table className="overflow-visible">
<TableHead>
<TableRow>
<TableHeaderCell>Email</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell>
<TableHeaderCell>
<div className="flex">
<div className="ml-auto">Actions</div>
</div>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.email}</TableCell>
<TableCell>
<i>{user.role === "admin" ? "Admin" : "User"}</i>
</TableCell>
<TableCell>
<div className="flex justify-end space-x-2">
{user.role !== "admin" && (
<Button
onClick={async () => {
const res = await fetch(
"/api/manage/promote-user-to-admin",
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_email: user.email,
}),
}
);
if (!res.ok) {
const errorMsg = await res.text();
setPopup({
message: `Unable to promote user - ${errorMsg}`,
type: "error",
});
} else {
mutate("/api/manage/users");
setPopup({
message: "User promoted to admin user!",
type: "success",
});
}
}}
>
Promote to Admin User
</Button>
)}
{user.role === "admin" && (
<Button
onClick={async () => {
const res = await fetch(
"/api/manage/demote-admin-to-basic",
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_email: user.email,
}),
}
);
if (!res.ok) {
const errorMsg = await res.text();
setPopup({
message: `Unable to demote admin - ${errorMsg}`,
type: "error",
});
} else {
mutate("/api/manage/users");
setPopup({
message: "Admin demoted to basic user!",
type: "success",
});
}
}}
>
Demote to Basic User
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex flex-col gap-y-4">
<div className="flex gap-x-4">
<AddUserButton setPopup={setPopup} />
<div className="flex-grow">
<SearchBar
query={query}
setQuery={setQuery}
onSearch={() => setQ(query)}
/>
</div>
</div>
<UsersTables q={q} setPopup={setPopup} />
</div>
</div>
);
};
const AddUserButton = ({
setPopup,
}: {
setPopup: (spec: PopupSpec) => void;
}) => {
const [modal, setModal] = useState(false);
const onSuccess = () => {
mutate(
(key) => typeof key === "string" && key.startsWith("/api/manage/users")
);
setModal(false);
setPopup({
message: "Users invited!",
type: "success",
});
};
return (
<>
<Button className="w-fit" onClick={() => setModal(true)}>
<div className="flex">
<FiPlusSquare className="my-auto mr-2" />
Invite Users
</div>
</Button>
{modal && (
<Modal title="Bulk Add Users" onOutsideClick={() => setModal(false)}>
<div className="flex flex-col gap-y-4">
<Text className="font-medium text-base">
Add the email addresses to import, separated by whitespaces.
</Text>
<BulkAdd onSuccess={onSuccess} />
</div>
</Modal>
)}
</>
);
};
const Page = () => {
return (
<div className="mx-auto container">
<AdminPageTitle title="Manage Users" icon={<UsersIcon size={32} />} />
<UsersTable />
<SearchableTables />
</div>
);
};

View File

@ -79,7 +79,7 @@ const PageLink = ({
</div>
);
interface PageSelectorProps {
export interface PageSelectorProps {
currentPage: number;
totalPages: number;
onPageChange: (newPage: number) => void;

View File

@ -0,0 +1,89 @@
"use client";
import useSWRMutation from "swr/mutation";
import { RobotIcon } from "@/components/icons/icons";
import { withFormik, FormikProps, FormikErrors, Form, Field } from "formik";
import { BackButton } from "@/components/BackButton";
import { Card } from "@tremor/react";
import { AdminPageTitle } from "@/components/admin/Title";
import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { Button, Text } from "@tremor/react";
const WHITESPACE_SPLIT = /\s+/;
const EMAIL_REGEX = /[^@]+@[^.]+\.[^.]/;
const addUsers = async (url: string, { arg }: { arg: Array<string> }) => {
return await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ emails: arg }),
});
};
interface FormProps {
onSuccess: () => void;
}
interface FormValues {
emails: string;
}
const AddUserFormRenderer = ({
touched,
errors,
isSubmitting,
}: FormikProps<FormValues>) => (
<Form>
<div className="flex flex-col gap-y-4">
<Field id="emails" name="emails" as="textarea" className="p-4" />
{touched.emails && errors.emails && (
<div className="text-error text-sm">{errors.emails}</div>
)}
<Button
className="mx-auto"
color="green"
size="md"
type="submit"
disabled={isSubmitting}
>
Add!
</Button>
</div>
</Form>
);
const AddUserForm = withFormik<FormProps, FormValues>({
mapPropsToValues: (props) => {
return {
emails: "",
};
},
validate: (values: FormValues): FormikErrors<FormValues> => {
const emails = values.emails.trim().split(WHITESPACE_SPLIT);
if (!emails.some(Boolean)) {
return { emails: "Required" };
}
for (let email of emails) {
if (!email.match(EMAIL_REGEX)) {
return { emails: `${email} is not a valid email` };
}
}
return {};
},
handleSubmit: async (values: FormValues, formikBag) => {
const emails = values.emails.trim().split(WHITESPACE_SPLIT);
await addUsers("/api/manage/admin/users", { arg: emails }).then((res) => {
if (res.ok) {
formikBag.props.onSuccess();
}
});
},
})(AddUserFormRenderer);
const BulkAdd = ({ onSuccess }: FormProps) => {
return <AddUserForm onSuccess={onSuccess} />;
};
export default BulkAdd;

View File

@ -0,0 +1,20 @@
import {
PageSelector,
type PageSelectorProps as Props,
} from "@/components/PageSelector";
const CenteredPageSelector = ({
currentPage,
totalPages,
onPageChange,
}: Props) => (
<div className="mx-auto text-center">
<PageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
);
export default CenteredPageSelector;

View File

@ -0,0 +1,111 @@
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { HidableSection } from "@/app/admin/assistants/HidableSection";
import {
Table,
TableHead,
TableRow,
TableHeaderCell,
TableBody,
TableCell,
Button,
} from "@tremor/react";
import userMutationFetcher from "@/lib/admin/users/userMutationFetcher";
import CenteredPageSelector from "./CenteredPageSelector";
import { type PageSelectorProps } from "@/components/PageSelector";
import useSWR from "swr";
import { type User, UserStatus } from "@/lib/types";
import useSWRMutation from "swr/mutation";
interface Props {
users: Array<User>;
setPopup: (spec: PopupSpec) => void;
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 })}>
Uninivite User
</Button>
);
};
const InvitedUserTable = ({
users,
setPopup,
currentPage,
totalPages,
onPageChange,
mutate,
}: 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 (
<HidableSection sectionTitle="Invited Users">
<>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
<Table className="overflow-visible">
<TableHead>
<TableRow>
<TableHeaderCell>Email</TableHeaderCell>
<TableHeaderCell>
<div className="flex justify-end">Actions</div>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.email}>
<TableCell>{user.email}</TableCell>
<TableCell>
<div className="flex justify-end">
<RemoveUserButton
user={user}
onSuccess={onRemovalSuccess}
onError={onRemovalError}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
</HidableSection>
);
};
export default InvitedUserTable;

View File

@ -0,0 +1,203 @@
import { type User, UserStatus } 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,
TableHeaderCell,
TableBody,
TableCell,
Button,
} from "@tremor/react";
import { PageSelector } from "@/components/PageSelector";
interface Props {
users: Array<User>;
setPopup: (spec: PopupSpec) => void;
mutate: () => void;
}
const PromoterButton = ({
user,
promote,
onSuccess,
onError,
}: {
user: User;
promote: boolean;
onSuccess: () => void;
onError: (message: string) => void;
}) => {
const { trigger, isMutating } = useSWRMutation(
promote
? "/api/manage/promote-user-to-admin"
: "/api/manage/demote-admin-to-basic",
userMutationFetcher,
{ onSuccess, onError }
);
return (
<Button
className="w-min"
onClick={() => trigger({ user_email: user.email })}
disabled={isMutating}
>
{promote ? "Promote" : "Demote"} to {promote ? "Admin" : "Basic"} User
</Button>
);
};
const DeactivaterButton = ({
user,
deactivate,
onSuccess,
onError,
}: {
user: User;
deactivate: boolean;
onSuccess: () => void;
onError: (message: string) => void;
}) => {
const { trigger, isMutating } = useSWRMutation(
deactivate
? "/api/manage/admin/deactivate-user"
: "/api/manage/admin/activate-user",
userMutationFetcher,
{ onSuccess, onError }
);
return (
<Button
className="w-min"
onClick={() => trigger({ user_email: user.email })}
disabled={isMutating}
>
{deactivate ? "Deactivate" : "Activate"} Access
</Button>
);
};
const SignedUpUserTable = ({
users,
setPopup,
currentPage,
totalPages,
onPageChange,
mutate,
}: Props & PageSelectorProps) => {
if (!users.length) return null;
const onSuccess = (message: string) => {
mutate();
setPopup({
message,
type: "success",
});
};
const onError = (message: string) => {
setPopup({
message,
type: "error",
});
};
const onPromotionSuccess = () => {
onSuccess("User promoted to admin user!");
};
const onPromotionError = (errorMsg: string) => {
onError(`Unable to promote user - ${errorMsg}`);
};
const onDemotionSuccess = () => {
onSuccess("Admin demoted to basic user!");
};
const onDemotionError = (errorMsg: string) => {
onError(`Unable to demote admin - ${errorMsg}`);
};
const onDeactivateSuccess = () => {
mutate();
setPopup({
message: "User deactivated!",
type: "success",
});
};
const onDeactivateError = (errorMsg: string) => {
setPopup({
message: `Unable to deactivate user - ${errorMsg}`,
type: "error",
});
};
const onActivateSuccess = () => {
mutate();
setPopup({
message: "User activate!",
type: "success",
});
};
const onActivateError = (errorMsg: string) => {
setPopup({
message: `Unable to activate user - ${errorMsg}`,
type: "error",
});
};
return (
<HidableSection sectionTitle="Signed Up Users">
<>
{totalPages > 1 ? (
<CenteredPageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
) : null}
<Table className="overflow-visible">
<TableHead>
<TableRow>
<TableHeaderCell>Email</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>
<div className="flex">
<div className="ml-auto">Actions</div>
</div>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.email}</TableCell>
<TableCell>
<i>{user.role === "admin" ? "Admin" : "User"}</i>
</TableCell>
<TableCell>
<i>{user.status === "live" ? "Active" : "Inactive"}</i>
</TableCell>
<TableCell>
<div className="flex flex-col items-end gap-y-2">
<PromoterButton
user={user}
promote={user.role !== "admin"}
onSuccess={onPromotionSuccess}
onError={onPromotionError}
/>
<DeactivaterButton
user={user}
deactivate={user.status === UserStatus.live}
onSuccess={onDeactivateSuccess}
onError={onDeactivateError}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
</HidableSection>
);
};
export default SignedUpUserTable;

View File

@ -0,0 +1,20 @@
const userMutationFetcher = async (
url: string,
{ arg }: { arg: { user_email: string } }
) => {
return fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_email: arg.user_email,
}),
}).then(async (res) => {
if (res.ok) return res.json();
const text = await res.text();
throw Error(text);
});
};
export default userMutationFetcher;

View File

@ -4,6 +4,12 @@ export interface UserPreferences {
chosen_assistants: number[] | null;
}
export enum UserStatus {
live = "live",
invited = "invited",
deactivated = "deactivated",
}
export interface User {
id: string;
email: string;
@ -12,6 +18,7 @@ export interface User {
is_verified: string;
role: "basic" | "admin";
preferences: UserPreferences;
status: UserStatus;
}
export interface MinimalUserSnapshot {