mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-04-03 09:28:25 +02:00
User Management: Invite, Deactivate, Search, & Paginate (#1631)
This commit is contained in:
parent
4e15ba78d5
commit
58b5e25c97
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@
|
||||
.idea
|
||||
/deployment/data/nginx/app.conf
|
||||
.vscode/launch.json
|
||||
*.sw?
|
||||
|
@ -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
|
||||
|
21
backend/danswer/auth/invited_users.py
Normal file
21
backend/danswer/auth/invited_users.py
Normal 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)
|
@ -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
|
||||
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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"""
|
||||
|
@ -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]
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -79,7 +79,7 @@ const PageLink = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PageSelectorProps {
|
||||
export interface PageSelectorProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (newPage: number) => void;
|
||||
|
89
web/src/components/admin/users/BulkAdd.tsx
Normal file
89
web/src/components/admin/users/BulkAdd.tsx
Normal 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;
|
20
web/src/components/admin/users/CenteredPageSelector.tsx
Normal file
20
web/src/components/admin/users/CenteredPageSelector.tsx
Normal 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;
|
111
web/src/components/admin/users/InvitedUserTable.tsx
Normal file
111
web/src/components/admin/users/InvitedUserTable.tsx
Normal 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;
|
203
web/src/components/admin/users/SignedUpUserTable.tsx
Normal file
203
web/src/components/admin/users/SignedUpUserTable.tsx
Normal 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;
|
20
web/src/lib/admin/users/userMutationFetcher.ts
Normal file
20
web/src/lib/admin/users/userMutationFetcher.ts
Normal 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;
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user