mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-27 12:29:41 +02:00
Pagination Hook (#3494)
* Backend changes for pagination hook + Paginated users table * Frontend changes for pagination hook * Fix invited users endpoint * Fix layout shift & add enter to submit user invites * mypy * Cleanup * Resolve PR concerns & remove UserStatus * Fix errors --------- Co-authored-by: hagen-danswer <hagen@danswer.ai>
This commit is contained in:
@@ -13,9 +13,8 @@ from ee.onyx.db.usage_export import get_all_empty_chat_message_entries
|
|||||||
from ee.onyx.db.usage_export import write_usage_report
|
from ee.onyx.db.usage_export import write_usage_report
|
||||||
from ee.onyx.server.reporting.usage_export_models import UsageReportMetadata
|
from ee.onyx.server.reporting.usage_export_models import UsageReportMetadata
|
||||||
from ee.onyx.server.reporting.usage_export_models import UserSkeleton
|
from ee.onyx.server.reporting.usage_export_models import UserSkeleton
|
||||||
from onyx.auth.schemas import UserStatus
|
|
||||||
from onyx.configs.constants import FileOrigin
|
from onyx.configs.constants import FileOrigin
|
||||||
from onyx.db.users import list_users
|
from onyx.db.users import get_all_users
|
||||||
from onyx.file_store.constants import MAX_IN_MEMORY_SIZE
|
from onyx.file_store.constants import MAX_IN_MEMORY_SIZE
|
||||||
from onyx.file_store.file_store import FileStore
|
from onyx.file_store.file_store import FileStore
|
||||||
from onyx.file_store.file_store import get_default_file_store
|
from onyx.file_store.file_store import get_default_file_store
|
||||||
@@ -84,15 +83,15 @@ def generate_user_report(
|
|||||||
max_size=MAX_IN_MEMORY_SIZE, mode="w+"
|
max_size=MAX_IN_MEMORY_SIZE, mode="w+"
|
||||||
) as temp_file:
|
) as temp_file:
|
||||||
csvwriter = csv.writer(temp_file, delimiter=",")
|
csvwriter = csv.writer(temp_file, delimiter=",")
|
||||||
csvwriter.writerow(["user_id", "status"])
|
csvwriter.writerow(["user_id", "is_active"])
|
||||||
|
|
||||||
users = list_users(db_session)
|
users = get_all_users(db_session)
|
||||||
for user in users:
|
for user in users:
|
||||||
user_skeleton = UserSkeleton(
|
user_skeleton = UserSkeleton(
|
||||||
user_id=str(user.id),
|
user_id=str(user.id),
|
||||||
status=UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED,
|
is_active=user.is_active,
|
||||||
)
|
)
|
||||||
csvwriter.writerow([user_skeleton.user_id, user_skeleton.status])
|
csvwriter.writerow([user_skeleton.user_id, user_skeleton.is_active])
|
||||||
|
|
||||||
temp_file.seek(0)
|
temp_file.seek(0)
|
||||||
file_store.save_file(
|
file_store.save_file(
|
||||||
|
@@ -4,8 +4,6 @@ from uuid import UUID
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from onyx.auth.schemas import UserStatus
|
|
||||||
|
|
||||||
|
|
||||||
class FlowType(str, Enum):
|
class FlowType(str, Enum):
|
||||||
CHAT = "chat"
|
CHAT = "chat"
|
||||||
@@ -22,7 +20,7 @@ class ChatMessageSkeleton(BaseModel):
|
|||||||
|
|
||||||
class UserSkeleton(BaseModel):
|
class UserSkeleton(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
status: UserStatus
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
class UsageReportMetadata(BaseModel):
|
class UsageReportMetadata(BaseModel):
|
||||||
|
@@ -33,12 +33,6 @@ class UserRole(str, Enum):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class UserStatus(str, Enum):
|
|
||||||
LIVE = "live"
|
|
||||||
INVITED = "invited"
|
|
||||||
DEACTIVATED = "deactivated"
|
|
||||||
|
|
||||||
|
|
||||||
class UserRead(schemas.BaseUser[uuid.UUID]):
|
class UserRead(schemas.BaseUser[uuid.UUID]):
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -6,10 +7,14 @@ from fastapi_users.password import PasswordHelper
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.sql import expression
|
||||||
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
from sqlalchemy.sql.elements import KeyedColumnElement
|
||||||
|
|
||||||
from onyx.auth.invited_users import get_invited_users
|
from onyx.auth.invited_users import get_invited_users
|
||||||
from onyx.auth.invited_users import write_invited_users
|
from onyx.auth.invited_users import write_invited_users
|
||||||
from onyx.auth.schemas import UserRole
|
from onyx.auth.schemas import UserRole
|
||||||
|
from onyx.db.api_key import DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN
|
||||||
from onyx.db.models import DocumentSet__User
|
from onyx.db.models import DocumentSet__User
|
||||||
from onyx.db.models import Persona__User
|
from onyx.db.models import Persona__User
|
||||||
from onyx.db.models import SamlAccount
|
from onyx.db.models import SamlAccount
|
||||||
@@ -90,8 +95,10 @@ def validate_user_role_update(requested_role: UserRole, current_role: UserRole)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_users(
|
def get_all_users(
|
||||||
db_session: Session, email_filter_string: str = "", include_external: bool = False
|
db_session: Session,
|
||||||
|
email_filter_string: str | None = None,
|
||||||
|
include_external: bool = False,
|
||||||
) -> Sequence[User]:
|
) -> Sequence[User]:
|
||||||
"""List all users. No pagination as of now, as the # of users
|
"""List all users. No pagination as of now, as the # of users
|
||||||
is assumed to be relatively small (<< 1 million)"""
|
is assumed to be relatively small (<< 1 million)"""
|
||||||
@@ -102,7 +109,7 @@ def list_users(
|
|||||||
if not include_external:
|
if not include_external:
|
||||||
where_clause.append(User.role != UserRole.EXT_PERM_USER)
|
where_clause.append(User.role != UserRole.EXT_PERM_USER)
|
||||||
|
|
||||||
if email_filter_string:
|
if email_filter_string is not None:
|
||||||
where_clause.append(User.email.ilike(f"%{email_filter_string}%")) # type: ignore
|
where_clause.append(User.email.ilike(f"%{email_filter_string}%")) # type: ignore
|
||||||
|
|
||||||
stmt = stmt.where(*where_clause)
|
stmt = stmt.where(*where_clause)
|
||||||
@@ -110,13 +117,101 @@ def list_users(
|
|||||||
return db_session.scalars(stmt).unique().all()
|
return db_session.scalars(stmt).unique().all()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_accepted_user_where_clause(
|
||||||
|
email_filter_string: str | None = None,
|
||||||
|
roles_filter: list[UserRole] = [],
|
||||||
|
include_external: bool = False,
|
||||||
|
is_active_filter: bool | None = None,
|
||||||
|
) -> list[ColumnElement[bool]]:
|
||||||
|
"""
|
||||||
|
Generates a SQLAlchemy where clause for filtering users based on the provided parameters.
|
||||||
|
This is used to build the filters for the function that retrieves the users for the users table in the admin panel.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- email_filter_string: A substring to filter user emails. Only users whose emails contain this substring will be included.
|
||||||
|
- is_active_filter: When True, only active users will be included. When False, only inactive users will be included.
|
||||||
|
- roles_filter: A list of user roles to filter by. Only users with roles in this list will be included.
|
||||||
|
- include_external: If False, external permissioned users will be excluded.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- list: A list of conditions to be used in a SQLAlchemy query to filter users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Access table columns directly via __table__.c to get proper SQLAlchemy column types
|
||||||
|
# This ensures type checking works correctly for SQL operations like ilike, endswith, and is_
|
||||||
|
email_col: KeyedColumnElement[Any] = User.__table__.c.email
|
||||||
|
is_active_col: KeyedColumnElement[Any] = User.__table__.c.is_active
|
||||||
|
|
||||||
|
where_clause: list[ColumnElement[bool]] = [
|
||||||
|
expression.not_(email_col.endswith(DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN))
|
||||||
|
]
|
||||||
|
|
||||||
|
if not include_external:
|
||||||
|
where_clause.append(User.role != UserRole.EXT_PERM_USER)
|
||||||
|
|
||||||
|
if email_filter_string is not None:
|
||||||
|
where_clause.append(email_col.ilike(f"%{email_filter_string}%"))
|
||||||
|
|
||||||
|
if roles_filter:
|
||||||
|
where_clause.append(User.role.in_(roles_filter))
|
||||||
|
|
||||||
|
if is_active_filter is not None:
|
||||||
|
where_clause.append(is_active_col.is_(is_active_filter))
|
||||||
|
|
||||||
|
return where_clause
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_of_filtered_users(
|
||||||
|
db_session: Session,
|
||||||
|
page_size: int,
|
||||||
|
page_num: int,
|
||||||
|
email_filter_string: str | None = None,
|
||||||
|
is_active_filter: bool | None = None,
|
||||||
|
roles_filter: list[UserRole] = [],
|
||||||
|
include_external: bool = False,
|
||||||
|
) -> Sequence[User]:
|
||||||
|
users_stmt = select(User)
|
||||||
|
|
||||||
|
where_clause = _get_accepted_user_where_clause(
|
||||||
|
email_filter_string=email_filter_string,
|
||||||
|
roles_filter=roles_filter,
|
||||||
|
include_external=include_external,
|
||||||
|
is_active_filter=is_active_filter,
|
||||||
|
)
|
||||||
|
# Apply pagination
|
||||||
|
users_stmt = users_stmt.offset((page_num) * page_size).limit(page_size)
|
||||||
|
# Apply filtering
|
||||||
|
users_stmt = users_stmt.where(*where_clause)
|
||||||
|
|
||||||
|
return db_session.scalars(users_stmt).unique().all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_total_filtered_users_count(
|
||||||
|
db_session: Session,
|
||||||
|
email_filter_string: str | None = None,
|
||||||
|
is_active_filter: bool | None = None,
|
||||||
|
roles_filter: list[UserRole] = [],
|
||||||
|
include_external: bool = False,
|
||||||
|
) -> int:
|
||||||
|
where_clause = _get_accepted_user_where_clause(
|
||||||
|
email_filter_string=email_filter_string,
|
||||||
|
roles_filter=roles_filter,
|
||||||
|
include_external=include_external,
|
||||||
|
is_active_filter=is_active_filter,
|
||||||
|
)
|
||||||
|
total_count_stmt = select(func.count()).select_from(User)
|
||||||
|
# Apply filtering
|
||||||
|
total_count_stmt = total_count_stmt.where(*where_clause)
|
||||||
|
|
||||||
|
return db_session.scalar(total_count_stmt) or 0
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_email(email: str, db_session: Session) -> User | None:
|
def get_user_by_email(email: str, db_session: Session) -> User | None:
|
||||||
user = (
|
user = (
|
||||||
db_session.query(User)
|
db_session.query(User)
|
||||||
.filter(func.lower(User.email) == func.lower(email))
|
.filter(func.lower(User.email) == func.lower(email))
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import math
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
@@ -48,7 +47,8 @@ from onyx.server.documents.models import CCStatusUpdateRequest
|
|||||||
from onyx.server.documents.models import ConnectorCredentialPairIdentifier
|
from onyx.server.documents.models import ConnectorCredentialPairIdentifier
|
||||||
from onyx.server.documents.models import ConnectorCredentialPairMetadata
|
from onyx.server.documents.models import ConnectorCredentialPairMetadata
|
||||||
from onyx.server.documents.models import DocumentSyncStatus
|
from onyx.server.documents.models import DocumentSyncStatus
|
||||||
from onyx.server.documents.models import PaginatedIndexAttempts
|
from onyx.server.documents.models import IndexAttemptSnapshot
|
||||||
|
from onyx.server.documents.models import PaginatedReturn
|
||||||
from onyx.server.models import StatusResponse
|
from onyx.server.models import StatusResponse
|
||||||
from onyx.utils.logger import setup_logger
|
from onyx.utils.logger import setup_logger
|
||||||
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
||||||
@@ -64,7 +64,7 @@ def get_cc_pair_index_attempts(
|
|||||||
page_size: int = Query(10, ge=1, le=1000),
|
page_size: int = Query(10, ge=1, le=1000),
|
||||||
user: User | None = Depends(current_curator_or_admin_user),
|
user: User | None = Depends(current_curator_or_admin_user),
|
||||||
db_session: Session = Depends(get_session),
|
db_session: Session = Depends(get_session),
|
||||||
) -> PaginatedIndexAttempts:
|
) -> PaginatedReturn[IndexAttemptSnapshot]:
|
||||||
cc_pair = get_connector_credential_pair_from_id(
|
cc_pair = get_connector_credential_pair_from_id(
|
||||||
cc_pair_id, db_session, user, get_editable=False
|
cc_pair_id, db_session, user, get_editable=False
|
||||||
)
|
)
|
||||||
@@ -82,10 +82,12 @@ def get_cc_pair_index_attempts(
|
|||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
)
|
)
|
||||||
return PaginatedIndexAttempts.from_models(
|
return PaginatedReturn(
|
||||||
index_attempt_models=index_attempts,
|
items=[
|
||||||
page=page,
|
IndexAttemptSnapshot.from_index_attempt_db_model(index_attempt)
|
||||||
total_pages=math.ceil(total_count / page_size),
|
for index_attempt in index_attempts
|
||||||
|
],
|
||||||
|
total_items=total_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Generic
|
||||||
|
from typing import TypeVar
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -19,6 +21,8 @@ from onyx.db.models import IndexAttempt
|
|||||||
from onyx.db.models import IndexAttemptError as DbIndexAttemptError
|
from onyx.db.models import IndexAttemptError as DbIndexAttemptError
|
||||||
from onyx.db.models import IndexingStatus
|
from onyx.db.models import IndexingStatus
|
||||||
from onyx.db.models import TaskStatus
|
from onyx.db.models import TaskStatus
|
||||||
|
from onyx.server.models import FullUserSnapshot
|
||||||
|
from onyx.server.models import InvitedUserSnapshot
|
||||||
from onyx.server.utils import mask_credential_dict
|
from onyx.server.utils import mask_credential_dict
|
||||||
|
|
||||||
|
|
||||||
@@ -201,26 +205,19 @@ class IndexAttemptError(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PaginatedIndexAttempts(BaseModel):
|
# These are the types currently supported by the pagination hook
|
||||||
index_attempts: list[IndexAttemptSnapshot]
|
# More api endpoints can be refactored and be added here for use with the pagination hook
|
||||||
page: int
|
PaginatedType = TypeVar(
|
||||||
total_pages: int
|
"PaginatedType",
|
||||||
|
IndexAttemptSnapshot,
|
||||||
|
FullUserSnapshot,
|
||||||
|
InvitedUserSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_models(
|
class PaginatedReturn(BaseModel, Generic[PaginatedType]):
|
||||||
cls,
|
items: list[PaginatedType]
|
||||||
index_attempt_models: list[IndexAttempt],
|
total_items: int
|
||||||
page: int,
|
|
||||||
total_pages: int,
|
|
||||||
) -> "PaginatedIndexAttempts":
|
|
||||||
return cls(
|
|
||||||
index_attempts=[
|
|
||||||
IndexAttemptSnapshot.from_index_attempt_db_model(index_attempt_model)
|
|
||||||
for index_attempt_model in index_attempt_models
|
|
||||||
],
|
|
||||||
page=page,
|
|
||||||
total_pages=total_pages,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CCPairFullInfo(BaseModel):
|
class CCPairFullInfo(BaseModel):
|
||||||
|
@@ -10,6 +10,7 @@ from fastapi import APIRouter
|
|||||||
from fastapi import Body
|
from fastapi import Body
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from fastapi import Query
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from psycopg2.errors import UniqueViolation
|
from psycopg2.errors import UniqueViolation
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -27,7 +28,6 @@ from onyx.auth.invited_users import write_invited_users
|
|||||||
from onyx.auth.noauth_user import fetch_no_auth_user
|
from onyx.auth.noauth_user import fetch_no_auth_user
|
||||||
from onyx.auth.noauth_user import set_no_auth_user_preferences
|
from onyx.auth.noauth_user import set_no_auth_user_preferences
|
||||||
from onyx.auth.schemas import UserRole
|
from onyx.auth.schemas import UserRole
|
||||||
from onyx.auth.schemas import UserStatus
|
|
||||||
from onyx.auth.users import anonymous_user_enabled
|
from onyx.auth.users import anonymous_user_enabled
|
||||||
from onyx.auth.users import current_admin_user
|
from onyx.auth.users import current_admin_user
|
||||||
from onyx.auth.users import current_curator_or_admin_user
|
from onyx.auth.users import current_curator_or_admin_user
|
||||||
@@ -45,10 +45,13 @@ from onyx.db.engine import get_session
|
|||||||
from onyx.db.models import AccessToken
|
from onyx.db.models import AccessToken
|
||||||
from onyx.db.models import User
|
from onyx.db.models import User
|
||||||
from onyx.db.users import delete_user_from_db
|
from onyx.db.users import delete_user_from_db
|
||||||
|
from onyx.db.users import get_all_users
|
||||||
|
from onyx.db.users import get_page_of_filtered_users
|
||||||
|
from onyx.db.users import get_total_filtered_users_count
|
||||||
from onyx.db.users import get_user_by_email
|
from onyx.db.users import get_user_by_email
|
||||||
from onyx.db.users import list_users
|
|
||||||
from onyx.db.users import validate_user_role_update
|
from onyx.db.users import validate_user_role_update
|
||||||
from onyx.key_value_store.factory import get_kv_store
|
from onyx.key_value_store.factory import get_kv_store
|
||||||
|
from onyx.server.documents.models import PaginatedReturn
|
||||||
from onyx.server.manage.models import AllUsersResponse
|
from onyx.server.manage.models import AllUsersResponse
|
||||||
from onyx.server.manage.models import AutoScrollRequest
|
from onyx.server.manage.models import AutoScrollRequest
|
||||||
from onyx.server.manage.models import UserByEmail
|
from onyx.server.manage.models import UserByEmail
|
||||||
@@ -65,10 +68,8 @@ from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
|||||||
from shared_configs.configs import MULTI_TENANT
|
from shared_configs.configs import MULTI_TENANT
|
||||||
|
|
||||||
logger = setup_logger()
|
logger = setup_logger()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
USERS_PAGE_SIZE = 10
|
USERS_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
@@ -113,21 +114,68 @@ def set_user_role(
|
|||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manage/users/accepted")
|
||||||
|
def list_accepted_users(
|
||||||
|
q: str | None = Query(default=None),
|
||||||
|
page_num: int = Query(0, ge=0),
|
||||||
|
page_size: int = Query(10, ge=1, le=1000),
|
||||||
|
roles: list[UserRole] = Query(default=[]),
|
||||||
|
is_active: bool | None = Query(default=None),
|
||||||
|
_: User | None = Depends(current_admin_user),
|
||||||
|
db_session: Session = Depends(get_session),
|
||||||
|
) -> PaginatedReturn[FullUserSnapshot]:
|
||||||
|
filtered_accepted_users = get_page_of_filtered_users(
|
||||||
|
db_session=db_session,
|
||||||
|
page_size=page_size,
|
||||||
|
page_num=page_num,
|
||||||
|
email_filter_string=q,
|
||||||
|
is_active_filter=is_active,
|
||||||
|
roles_filter=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_accepted_users_count = get_total_filtered_users_count(
|
||||||
|
db_session=db_session,
|
||||||
|
email_filter_string=q,
|
||||||
|
is_active_filter=is_active,
|
||||||
|
roles_filter=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not filtered_accepted_users:
|
||||||
|
logger.info("No users found")
|
||||||
|
return PaginatedReturn(
|
||||||
|
items=[],
|
||||||
|
total_items=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PaginatedReturn(
|
||||||
|
items=[
|
||||||
|
FullUserSnapshot.from_user_model(user) for user in filtered_accepted_users
|
||||||
|
],
|
||||||
|
total_items=total_accepted_users_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manage/users/invited")
|
||||||
|
def list_invited_users(
|
||||||
|
_: User | None = Depends(current_admin_user),
|
||||||
|
) -> list[InvitedUserSnapshot]:
|
||||||
|
invited_emails = get_invited_users()
|
||||||
|
|
||||||
|
return [InvitedUserSnapshot(email=email) for email in invited_emails]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/manage/users")
|
@router.get("/manage/users")
|
||||||
def list_all_users(
|
def list_all_users(
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
accepted_page: int | None = None,
|
accepted_page: int | None = None,
|
||||||
slack_users_page: int | None = None,
|
slack_users_page: int | None = None,
|
||||||
invited_page: int | None = None,
|
invited_page: int | None = None,
|
||||||
user: User | None = Depends(current_curator_or_admin_user),
|
_: User | None = Depends(current_curator_or_admin_user),
|
||||||
db_session: Session = Depends(get_session),
|
db_session: Session = Depends(get_session),
|
||||||
) -> AllUsersResponse:
|
) -> AllUsersResponse:
|
||||||
if not q:
|
|
||||||
q = ""
|
|
||||||
|
|
||||||
users = [
|
users = [
|
||||||
user
|
user
|
||||||
for user in list_users(db_session, email_filter_string=q)
|
for user in get_all_users(db_session, email_filter_string=q)
|
||||||
if not is_api_key_email_address(user.email)
|
if not is_api_key_email_address(user.email)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -154,9 +202,7 @@ def list_all_users(
|
|||||||
id=user.id,
|
id=user.id,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
status=(
|
is_active=user.is_active,
|
||||||
UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
for user in accepted_users
|
for user in accepted_users
|
||||||
],
|
],
|
||||||
@@ -165,9 +211,7 @@ def list_all_users(
|
|||||||
id=user.id,
|
id=user.id,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
status=(
|
is_active=user.is_active,
|
||||||
UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
for user in slack_users
|
for user in slack_users
|
||||||
],
|
],
|
||||||
@@ -184,7 +228,7 @@ def list_all_users(
|
|||||||
id=user.id,
|
id=user.id,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
status=UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED,
|
is_active=user.is_active,
|
||||||
)
|
)
|
||||||
for user in accepted_users
|
for user in accepted_users
|
||||||
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
|
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
|
||||||
@@ -193,7 +237,7 @@ def list_all_users(
|
|||||||
id=user.id,
|
id=user.id,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
status=UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED,
|
is_active=user.is_active,
|
||||||
)
|
)
|
||||||
for user in slack_users
|
for user in slack_users
|
||||||
][
|
][
|
||||||
@@ -412,7 +456,7 @@ def list_all_users_basic_info(
|
|||||||
_: User | None = Depends(current_user),
|
_: User | None = Depends(current_user),
|
||||||
db_session: Session = Depends(get_session),
|
db_session: Session = Depends(get_session),
|
||||||
) -> list[MinimalUserSnapshot]:
|
) -> list[MinimalUserSnapshot]:
|
||||||
users = list_users(db_session)
|
users = get_all_users(db_session)
|
||||||
return [MinimalUserSnapshot(id=user.id, email=user.email) for user in users]
|
return [MinimalUserSnapshot(id=user.id, email=user.email) for user in users]
|
||||||
|
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from onyx.auth.schemas import UserRole
|
from onyx.auth.schemas import UserRole
|
||||||
from onyx.auth.schemas import UserStatus
|
from onyx.db.models import User
|
||||||
|
|
||||||
|
|
||||||
DataT = TypeVar("DataT")
|
DataT = TypeVar("DataT")
|
||||||
@@ -35,7 +35,16 @@ class FullUserSnapshot(BaseModel):
|
|||||||
id: UUID
|
id: UUID
|
||||||
email: str
|
email: str
|
||||||
role: UserRole
|
role: UserRole
|
||||||
status: UserStatus
|
is_active: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_user_model(cls, user: User) -> "FullUserSnapshot":
|
||||||
|
return cls(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InvitedUserSnapshot(BaseModel):
|
class InvitedUserSnapshot(BaseModel):
|
||||||
|
@@ -43,17 +43,11 @@ logger = getLogger(__name__)
|
|||||||
# GLOBAL_CURATOR = "global_curator"
|
# GLOBAL_CURATOR = "global_curator"
|
||||||
|
|
||||||
|
|
||||||
# class UserStatus(str, Enum):
|
|
||||||
# LIVE = "live"
|
|
||||||
# INVITED = "invited"
|
|
||||||
# DEACTIVATED = "deactivated"
|
|
||||||
|
|
||||||
|
|
||||||
# class FullUserSnapshot(BaseModel):
|
# class FullUserSnapshot(BaseModel):
|
||||||
# id: UUID
|
# id: UUID
|
||||||
# email: str
|
# email: str
|
||||||
# role: UserRole
|
# role: UserRole
|
||||||
# status: UserStatus
|
# is_active: bool
|
||||||
|
|
||||||
|
|
||||||
# class InvitedUserSnapshot(BaseModel):
|
# class InvitedUserSnapshot(BaseModel):
|
||||||
|
@@ -2,17 +2,17 @@ from copy import deepcopy
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
from requests import HTTPError
|
||||||
|
|
||||||
from onyx.db.models import UserRole
|
from onyx.auth.schemas import UserRole
|
||||||
from onyx.server.manage.models import AllUsersResponse
|
from onyx.server.documents.models import PaginatedReturn
|
||||||
from onyx.server.models import FullUserSnapshot
|
from onyx.server.models import FullUserSnapshot
|
||||||
from onyx.server.models import InvitedUserSnapshot
|
|
||||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||||
from tests.integration.common_utils.constants import GENERAL_HEADERS
|
from tests.integration.common_utils.constants import GENERAL_HEADERS
|
||||||
from tests.integration.common_utils.test_models import DATestUser
|
from tests.integration.common_utils.test_models import DATestUser
|
||||||
|
|
||||||
|
|
||||||
DOMAIN = "test.com"
|
DOMAIN = "test.com"
|
||||||
DEFAULT_PASSWORD = "TestPassword123!"
|
DEFAULT_PASSWORD = "TestPassword123!"
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ class UserManager:
|
|||||||
def create(
|
def create(
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
|
is_first_user: bool = False,
|
||||||
) -> DATestUser:
|
) -> DATestUser:
|
||||||
if name is None:
|
if name is None:
|
||||||
name = f"test{str(uuid4())}"
|
name = f"test{str(uuid4())}"
|
||||||
@@ -47,11 +48,15 @@ class UserManager:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
role = UserRole.ADMIN if is_first_user else UserRole.BASIC
|
||||||
|
|
||||||
test_user = DATestUser(
|
test_user = DATestUser(
|
||||||
id=response.json()["id"],
|
id=response.json()["id"],
|
||||||
email=email,
|
email=email,
|
||||||
password=password,
|
password=password,
|
||||||
headers=deepcopy(GENERAL_HEADERS),
|
headers=deepcopy(GENERAL_HEADERS),
|
||||||
|
role=role,
|
||||||
|
is_active=True,
|
||||||
)
|
)
|
||||||
print(f"Created user {test_user.email}")
|
print(f"Created user {test_user.email}")
|
||||||
|
|
||||||
@@ -89,53 +94,148 @@ class UserManager:
|
|||||||
return test_user
|
return test_user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_role(user_to_verify: DATestUser, target_role: UserRole) -> bool:
|
def is_role(
|
||||||
|
user_to_verify: DATestUser,
|
||||||
|
target_role: UserRole,
|
||||||
|
) -> bool:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
url=f"{API_SERVER_URL}/me",
|
url=f"{API_SERVER_URL}/me",
|
||||||
headers=user_to_verify.headers,
|
headers=user_to_verify.headers,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
return target_role == UserRole(response.json().get("role", ""))
|
if user_to_verify.is_active is False:
|
||||||
|
with pytest.raises(HTTPError):
|
||||||
|
response.raise_for_status()
|
||||||
|
return user_to_verify.role == target_role
|
||||||
|
else:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
role_from_response = response.json().get("role", None)
|
||||||
|
|
||||||
|
if role_from_response is None:
|
||||||
|
return user_to_verify.role == target_role
|
||||||
|
|
||||||
|
return target_role == UserRole(role_from_response)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_role(
|
def set_role(
|
||||||
user_to_set: DATestUser,
|
user_to_set: DATestUser,
|
||||||
target_role: UserRole,
|
target_role: UserRole,
|
||||||
user_to_perform_action: DATestUser | None = None,
|
user_performing_action: DATestUser,
|
||||||
) -> None:
|
) -> DATestUser:
|
||||||
if user_to_perform_action is None:
|
|
||||||
user_to_perform_action = user_to_set
|
|
||||||
response = requests.patch(
|
response = requests.patch(
|
||||||
url=f"{API_SERVER_URL}/manage/set-user-role",
|
url=f"{API_SERVER_URL}/manage/set-user-role",
|
||||||
json={"user_email": user_to_set.email, "new_role": target_role.value},
|
json={"user_email": user_to_set.email, "new_role": target_role.value},
|
||||||
headers=user_to_perform_action.headers,
|
headers=user_performing_action.headers,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
new_user_updated_role = DATestUser(
|
||||||
|
id=user_to_set.id,
|
||||||
|
email=user_to_set.email,
|
||||||
|
password=user_to_set.password,
|
||||||
|
headers=user_to_set.headers,
|
||||||
|
role=target_role,
|
||||||
|
is_active=user_to_set.is_active,
|
||||||
|
)
|
||||||
|
return new_user_updated_role
|
||||||
|
|
||||||
|
# TODO: Add a way to check invited status
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify(
|
def is_status(user_to_verify: DATestUser, target_status: bool) -> bool:
|
||||||
user: DATestUser, user_to_perform_action: DATestUser | None = None
|
|
||||||
) -> None:
|
|
||||||
if user_to_perform_action is None:
|
|
||||||
user_to_perform_action = user
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
url=f"{API_SERVER_URL}/manage/users",
|
url=f"{API_SERVER_URL}/me",
|
||||||
headers=user_to_perform_action.headers
|
headers=user_to_verify.headers,
|
||||||
if user_to_perform_action
|
)
|
||||||
|
|
||||||
|
if target_status is False:
|
||||||
|
with pytest.raises(HTTPError):
|
||||||
|
response.raise_for_status()
|
||||||
|
else:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
is_active = response.json().get("is_active", None)
|
||||||
|
if is_active is None:
|
||||||
|
return user_to_verify.is_active == target_status
|
||||||
|
return target_status == is_active
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set_status(
|
||||||
|
user_to_set: DATestUser,
|
||||||
|
target_status: bool,
|
||||||
|
user_performing_action: DATestUser,
|
||||||
|
) -> DATestUser:
|
||||||
|
if target_status is True:
|
||||||
|
url_substring = "activate"
|
||||||
|
elif target_status is False:
|
||||||
|
url_substring = "deactivate"
|
||||||
|
response = requests.patch(
|
||||||
|
url=f"{API_SERVER_URL}/manage/admin/{url_substring}-user",
|
||||||
|
json={"user_email": user_to_set.email},
|
||||||
|
headers=user_performing_action.headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
new_user_updated_status = DATestUser(
|
||||||
|
id=user_to_set.id,
|
||||||
|
email=user_to_set.email,
|
||||||
|
password=user_to_set.password,
|
||||||
|
headers=user_to_set.headers,
|
||||||
|
role=user_to_set.role,
|
||||||
|
is_active=target_status,
|
||||||
|
)
|
||||||
|
return new_user_updated_status
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_test_users(
|
||||||
|
user_performing_action: DATestUser,
|
||||||
|
user_name_prefix: str,
|
||||||
|
count: int,
|
||||||
|
role: UserRole = UserRole.BASIC,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
) -> list[DATestUser]:
|
||||||
|
users_list = []
|
||||||
|
for i in range(1, count + 1):
|
||||||
|
user = UserManager.create(name=f"{user_name_prefix}_{i}")
|
||||||
|
if role != UserRole.BASIC:
|
||||||
|
user = UserManager.set_role(user, role, user_performing_action)
|
||||||
|
if is_active is not None:
|
||||||
|
user = UserManager.set_status(user, is_active, user_performing_action)
|
||||||
|
users_list.append(user)
|
||||||
|
return users_list
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_user_page(
|
||||||
|
page_num: int = 0,
|
||||||
|
page_size: int = 10,
|
||||||
|
search_query: str | None = None,
|
||||||
|
role_filter: list[UserRole] | None = None,
|
||||||
|
is_active_filter: bool | None = None,
|
||||||
|
user_performing_action: DATestUser | None = None,
|
||||||
|
) -> PaginatedReturn[FullUserSnapshot]:
|
||||||
|
query_params = {
|
||||||
|
"page_num": page_num,
|
||||||
|
"page_size": page_size,
|
||||||
|
"q": search_query if search_query else None,
|
||||||
|
"roles": [role.value for role in role_filter] if role_filter else None,
|
||||||
|
"is_active": is_active_filter if is_active_filter is not None else None,
|
||||||
|
}
|
||||||
|
# Remove None values
|
||||||
|
query_params = {
|
||||||
|
key: value for key, value in query_params.items() if value is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
url=f"{API_SERVER_URL}/manage/users/accepted?{urlencode(query_params, doseq=True)}",
|
||||||
|
headers=user_performing_action.headers
|
||||||
|
if user_performing_action
|
||||||
else GENERAL_HEADERS,
|
else GENERAL_HEADERS,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
all_users = AllUsersResponse(
|
paginated_result = PaginatedReturn(
|
||||||
accepted=[FullUserSnapshot(**user) for user in data["accepted"]],
|
items=[FullUserSnapshot(**user) for user in data["items"]],
|
||||||
invited=[InvitedUserSnapshot(**user) for user in data["invited"]],
|
total_items=data["total_items"],
|
||||||
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:
|
return paginated_result
|
||||||
if accepted_user.email == user.email and accepted_user.id == user.id:
|
|
||||||
return
|
|
||||||
raise ValueError(f"User {user.email} not found")
|
|
||||||
|
@@ -37,6 +37,8 @@ class DATestUser(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
password: str
|
password: str
|
||||||
headers: dict
|
headers: dict
|
||||||
|
role: UserRole
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
class DATestPersonaCategory(BaseModel):
|
class DATestPersonaCategory(BaseModel):
|
||||||
|
@@ -16,12 +16,12 @@ def test_multi_tenant_access_control(reset_multitenant: None) -> None:
|
|||||||
# Create Tenant 1 and its Admin User
|
# Create Tenant 1 and its Admin User
|
||||||
TenantManager.create("tenant_dev1", "test1@test.com", "Data Plane Registration")
|
TenantManager.create("tenant_dev1", "test1@test.com", "Data Plane Registration")
|
||||||
test_user1: DATestUser = UserManager.create(name="test1", email="test1@test.com")
|
test_user1: DATestUser = UserManager.create(name="test1", email="test1@test.com")
|
||||||
assert UserManager.verify_role(test_user1, UserRole.ADMIN)
|
assert UserManager.is_role(test_user1, UserRole.ADMIN)
|
||||||
|
|
||||||
# Create Tenant 2 and its Admin User
|
# Create Tenant 2 and its Admin User
|
||||||
TenantManager.create("tenant_dev2", "test2@test.com", "Data Plane Registration")
|
TenantManager.create("tenant_dev2", "test2@test.com", "Data Plane Registration")
|
||||||
test_user2: DATestUser = UserManager.create(name="test2", email="test2@test.com")
|
test_user2: DATestUser = UserManager.create(name="test2", email="test2@test.com")
|
||||||
assert UserManager.verify_role(test_user2, UserRole.ADMIN)
|
assert UserManager.is_role(test_user2, UserRole.ADMIN)
|
||||||
|
|
||||||
# Create connectors for Tenant 1
|
# Create connectors for Tenant 1
|
||||||
cc_pair_1: DATestCCPair = CCPairManager.create_from_scratch(
|
cc_pair_1: DATestCCPair = CCPairManager.create_from_scratch(
|
||||||
|
@@ -14,7 +14,7 @@ def test_tenant_creation(reset_multitenant: None) -> None:
|
|||||||
TenantManager.create("tenant_dev", "test@test.com", "Data Plane Registration")
|
TenantManager.create("tenant_dev", "test@test.com", "Data Plane Registration")
|
||||||
test_user: DATestUser = UserManager.create(name="test", email="test@test.com")
|
test_user: DATestUser = UserManager.create(name="test", email="test@test.com")
|
||||||
|
|
||||||
assert UserManager.verify_role(test_user, UserRole.ADMIN)
|
assert UserManager.is_role(test_user, UserRole.ADMIN)
|
||||||
|
|
||||||
test_credential = CredentialManager.create(
|
test_credential = CredentialManager.create(
|
||||||
name="admin_test_credential",
|
name="admin_test_credential",
|
||||||
|
@@ -10,6 +10,7 @@ from tests.integration.common_utils.managers.llm_provider import LLMProviderMana
|
|||||||
from tests.integration.common_utils.managers.user import build_email
|
from tests.integration.common_utils.managers.user import build_email
|
||||||
from tests.integration.common_utils.managers.user import DEFAULT_PASSWORD
|
from tests.integration.common_utils.managers.user import DEFAULT_PASSWORD
|
||||||
from tests.integration.common_utils.managers.user import UserManager
|
from tests.integration.common_utils.managers.user import UserManager
|
||||||
|
from tests.integration.common_utils.managers.user import UserRole
|
||||||
from tests.integration.common_utils.test_models import DATestLLMProvider
|
from tests.integration.common_utils.test_models import DATestLLMProvider
|
||||||
from tests.integration.common_utils.test_models import DATestUser
|
from tests.integration.common_utils.test_models import DATestUser
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ def admin_user() -> DATestUser | None:
|
|||||||
email=build_email("admin_user"),
|
email=build_email("admin_user"),
|
||||||
password=DEFAULT_PASSWORD,
|
password=DEFAULT_PASSWORD,
|
||||||
headers=GENERAL_HEADERS,
|
headers=GENERAL_HEADERS,
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
@@ -13,51 +13,51 @@ from tests.integration.common_utils.managers.user_group import UserGroupManager
|
|||||||
def test_user_role_setting_permissions(reset: None) -> None:
|
def test_user_role_setting_permissions(reset: None) -> None:
|
||||||
# Creating an admin user (first user created is automatically an admin)
|
# Creating an admin user (first user created is automatically an admin)
|
||||||
admin_user: DATestUser = UserManager.create(name="admin_user")
|
admin_user: DATestUser = UserManager.create(name="admin_user")
|
||||||
assert UserManager.verify_role(admin_user, UserRole.ADMIN)
|
assert UserManager.is_role(admin_user, UserRole.ADMIN)
|
||||||
|
|
||||||
# Creating a basic user
|
# Creating a basic user
|
||||||
basic_user: DATestUser = UserManager.create(name="basic_user")
|
basic_user: DATestUser = UserManager.create(name="basic_user")
|
||||||
assert UserManager.verify_role(basic_user, UserRole.BASIC)
|
assert UserManager.is_role(basic_user, UserRole.BASIC)
|
||||||
|
|
||||||
# Creating a curator
|
# Creating a curator
|
||||||
curator: DATestUser = UserManager.create(name="curator")
|
curator: DATestUser = UserManager.create(name="curator")
|
||||||
assert UserManager.verify_role(curator, UserRole.BASIC)
|
assert UserManager.is_role(curator, UserRole.BASIC)
|
||||||
|
|
||||||
# Creating a curator without adding to a group should not work
|
# Creating a curator without adding to a group should not work
|
||||||
with pytest.raises(HTTPError):
|
with pytest.raises(HTTPError):
|
||||||
UserManager.set_role(
|
UserManager.set_role(
|
||||||
user_to_set=curator,
|
user_to_set=curator,
|
||||||
target_role=UserRole.CURATOR,
|
target_role=UserRole.CURATOR,
|
||||||
user_to_perform_action=admin_user,
|
user_performing_action=admin_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
global_curator: DATestUser = UserManager.create(name="global_curator")
|
global_curator: DATestUser = UserManager.create(name="global_curator")
|
||||||
assert UserManager.verify_role(global_curator, UserRole.BASIC)
|
assert UserManager.is_role(global_curator, UserRole.BASIC)
|
||||||
|
|
||||||
# Setting the role of a global curator should not work for a basic user
|
# Setting the role of a global curator should not work for a basic user
|
||||||
with pytest.raises(HTTPError):
|
with pytest.raises(HTTPError):
|
||||||
UserManager.set_role(
|
UserManager.set_role(
|
||||||
user_to_set=global_curator,
|
user_to_set=global_curator,
|
||||||
target_role=UserRole.GLOBAL_CURATOR,
|
target_role=UserRole.GLOBAL_CURATOR,
|
||||||
user_to_perform_action=basic_user,
|
user_performing_action=basic_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Setting the role of a global curator should work for an admin user
|
# Setting the role of a global curator should work for an admin user
|
||||||
UserManager.set_role(
|
UserManager.set_role(
|
||||||
user_to_set=global_curator,
|
user_to_set=global_curator,
|
||||||
target_role=UserRole.GLOBAL_CURATOR,
|
target_role=UserRole.GLOBAL_CURATOR,
|
||||||
user_to_perform_action=admin_user,
|
user_performing_action=admin_user,
|
||||||
)
|
)
|
||||||
assert UserManager.verify_role(global_curator, UserRole.GLOBAL_CURATOR)
|
assert UserManager.is_role(global_curator, UserRole.GLOBAL_CURATOR)
|
||||||
|
|
||||||
# Setting the role of a global curator should not work for an invalid curator
|
# Setting the role of a global curator should not work for an invalid curator
|
||||||
with pytest.raises(HTTPError):
|
with pytest.raises(HTTPError):
|
||||||
UserManager.set_role(
|
UserManager.set_role(
|
||||||
user_to_set=global_curator,
|
user_to_set=global_curator,
|
||||||
target_role=UserRole.BASIC,
|
target_role=UserRole.BASIC,
|
||||||
user_to_perform_action=global_curator,
|
user_performing_action=global_curator,
|
||||||
)
|
)
|
||||||
assert UserManager.verify_role(global_curator, UserRole.GLOBAL_CURATOR)
|
assert UserManager.is_role(global_curator, UserRole.GLOBAL_CURATOR)
|
||||||
|
|
||||||
# Creating a user group
|
# Creating a user group
|
||||||
user_group_1 = UserGroupManager.create(
|
user_group_1 = UserGroupManager.create(
|
||||||
|
@@ -15,7 +15,7 @@ from tests.integration.common_utils.managers.user_group import UserGroupManager
|
|||||||
def test_whole_curator_flow(reset: None) -> None:
|
def test_whole_curator_flow(reset: None) -> None:
|
||||||
# Creating an admin user (first user created is automatically an admin)
|
# Creating an admin user (first user created is automatically an admin)
|
||||||
admin_user: DATestUser = UserManager.create(name="admin_user")
|
admin_user: DATestUser = UserManager.create(name="admin_user")
|
||||||
assert UserManager.verify_role(admin_user, UserRole.ADMIN)
|
assert UserManager.is_role(admin_user, UserRole.ADMIN)
|
||||||
|
|
||||||
# Creating a curator
|
# Creating a curator
|
||||||
curator: DATestUser = UserManager.create(name="curator")
|
curator: DATestUser = UserManager.create(name="curator")
|
||||||
@@ -36,7 +36,7 @@ def test_whole_curator_flow(reset: None) -> None:
|
|||||||
user_to_set_as_curator=curator,
|
user_to_set_as_curator=curator,
|
||||||
user_performing_action=admin_user,
|
user_performing_action=admin_user,
|
||||||
)
|
)
|
||||||
assert UserManager.verify_role(curator, UserRole.CURATOR)
|
assert UserManager.is_role(curator, UserRole.CURATOR)
|
||||||
|
|
||||||
# Creating a credential as curator
|
# Creating a credential as curator
|
||||||
test_credential = CredentialManager.create(
|
test_credential = CredentialManager.create(
|
||||||
@@ -92,19 +92,19 @@ def test_whole_curator_flow(reset: None) -> None:
|
|||||||
def test_global_curator_flow(reset: None) -> None:
|
def test_global_curator_flow(reset: None) -> None:
|
||||||
# Creating an admin user (first user created is automatically an admin)
|
# Creating an admin user (first user created is automatically an admin)
|
||||||
admin_user: DATestUser = UserManager.create(name="admin_user")
|
admin_user: DATestUser = UserManager.create(name="admin_user")
|
||||||
assert UserManager.verify_role(admin_user, UserRole.ADMIN)
|
assert UserManager.is_role(admin_user, UserRole.ADMIN)
|
||||||
|
|
||||||
# Creating a user
|
# Creating a user
|
||||||
global_curator: DATestUser = UserManager.create(name="global_curator")
|
global_curator: DATestUser = UserManager.create(name="global_curator")
|
||||||
assert UserManager.verify_role(global_curator, UserRole.BASIC)
|
assert UserManager.is_role(global_curator, UserRole.BASIC)
|
||||||
|
|
||||||
# Set the user to a global curator
|
# Set the user to a global curator
|
||||||
UserManager.set_role(
|
UserManager.set_role(
|
||||||
user_to_set=global_curator,
|
user_to_set=global_curator,
|
||||||
target_role=UserRole.GLOBAL_CURATOR,
|
target_role=UserRole.GLOBAL_CURATOR,
|
||||||
user_to_perform_action=admin_user,
|
user_performing_action=admin_user,
|
||||||
)
|
)
|
||||||
assert UserManager.verify_role(global_curator, UserRole.GLOBAL_CURATOR)
|
assert UserManager.is_role(global_curator, UserRole.GLOBAL_CURATOR)
|
||||||
|
|
||||||
# Creating a user group containing the global curator
|
# Creating a user group containing the global curator
|
||||||
user_group_1 = UserGroupManager.create(
|
user_group_1 = UserGroupManager.create(
|
||||||
|
168
backend/tests/integration/tests/users/test_user_pagination.py
Normal file
168
backend/tests/integration/tests/users/test_user_pagination.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
from onyx.auth.schemas import UserRole
|
||||||
|
from onyx.server.models import FullUserSnapshot
|
||||||
|
from tests.integration.common_utils.managers.user import UserManager
|
||||||
|
from tests.integration.common_utils.test_models import DATestUser
|
||||||
|
|
||||||
|
|
||||||
|
# Gets a page of users from the db that match the given parameters and then
|
||||||
|
# compares that returned page to the list of users passed into the function
|
||||||
|
# to verify that the pagination and filtering works as expected.
|
||||||
|
def _verify_user_pagination(
|
||||||
|
users: list[DATestUser],
|
||||||
|
page_size: int = 5,
|
||||||
|
search_query: str | None = None,
|
||||||
|
role_filter: list[UserRole] | None = None,
|
||||||
|
is_active_filter: bool | None = None,
|
||||||
|
user_performing_action: DATestUser | None = None,
|
||||||
|
) -> None:
|
||||||
|
retrieved_users: list[FullUserSnapshot] = []
|
||||||
|
|
||||||
|
for i in range(0, len(users), page_size):
|
||||||
|
paginated_result = UserManager.get_user_page(
|
||||||
|
page_num=i // page_size,
|
||||||
|
page_size=page_size,
|
||||||
|
search_query=search_query,
|
||||||
|
role_filter=role_filter,
|
||||||
|
is_active_filter=is_active_filter,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify that the total items is equal to the length of the users list
|
||||||
|
assert paginated_result.total_items == len(users)
|
||||||
|
# Verify that the number of items in the page is equal to the page size
|
||||||
|
assert len(paginated_result.items) == page_size
|
||||||
|
# Add the retrieved users to the list of retrieved users
|
||||||
|
retrieved_users.extend(paginated_result.items)
|
||||||
|
|
||||||
|
# Create a set of all the expected emails
|
||||||
|
all_expected_emails = set([user.email for user in users])
|
||||||
|
# Create a set of all the retrieved emails
|
||||||
|
all_retrieved_emails = set([user.email for user in retrieved_users])
|
||||||
|
|
||||||
|
# Verify that the set of retrieved emails is equal to the set of expected emails
|
||||||
|
assert all_expected_emails == all_retrieved_emails
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_user_role_and_status(users: list) -> None:
|
||||||
|
for user in users:
|
||||||
|
assert UserManager.is_role(user, user.role)
|
||||||
|
assert UserManager.is_status(user, user.is_active)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_pagination(reset: None) -> None:
|
||||||
|
# Create an admin user to perform actions
|
||||||
|
user_performing_action: DATestUser = UserManager.create(
|
||||||
|
name="admin_performing_action",
|
||||||
|
is_first_user=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create 9 admin users
|
||||||
|
admin_users: list[DATestUser] = UserManager.create_test_users(
|
||||||
|
user_name_prefix="admin",
|
||||||
|
count=9,
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the user_performing_action to the list of admins
|
||||||
|
admin_users.append(user_performing_action)
|
||||||
|
|
||||||
|
# Create 20 basic users
|
||||||
|
basic_users: list[DATestUser] = UserManager.create_test_users(
|
||||||
|
user_name_prefix="basic",
|
||||||
|
count=10,
|
||||||
|
role=UserRole.BASIC,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create 10 global curators
|
||||||
|
global_curators: list[DATestUser] = UserManager.create_test_users(
|
||||||
|
user_name_prefix="global_curator",
|
||||||
|
count=10,
|
||||||
|
role=UserRole.GLOBAL_CURATOR,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create 10 inactive admins
|
||||||
|
inactive_admins: list[DATestUser] = UserManager.create_test_users(
|
||||||
|
user_name_prefix="inactive_admin",
|
||||||
|
count=10,
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=False,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create 10 global curator users with an email containing "search"
|
||||||
|
searchable_curators: list[DATestUser] = UserManager.create_test_users(
|
||||||
|
user_name_prefix="search_curator",
|
||||||
|
count=10,
|
||||||
|
role=UserRole.GLOBAL_CURATOR,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Combine all the users lists into the all_users list
|
||||||
|
all_users: list[DATestUser] = (
|
||||||
|
admin_users
|
||||||
|
+ basic_users
|
||||||
|
+ global_curators
|
||||||
|
+ inactive_admins
|
||||||
|
+ searchable_curators
|
||||||
|
)
|
||||||
|
_verify_user_role_and_status(all_users)
|
||||||
|
|
||||||
|
# Verify pagination
|
||||||
|
_verify_user_pagination(
|
||||||
|
users=all_users,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify filtering by role
|
||||||
|
_verify_user_pagination(
|
||||||
|
users=admin_users + inactive_admins,
|
||||||
|
role_filter=[UserRole.ADMIN],
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
# Verify filtering by status
|
||||||
|
_verify_user_pagination(
|
||||||
|
users=inactive_admins,
|
||||||
|
is_active_filter=False,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
# Verify filtering by search query
|
||||||
|
_verify_user_pagination(
|
||||||
|
users=searchable_curators,
|
||||||
|
search_query="search",
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify filtering by role and status
|
||||||
|
_verify_user_pagination(
|
||||||
|
users=inactive_admins,
|
||||||
|
role_filter=[UserRole.ADMIN],
|
||||||
|
is_active_filter=False,
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify filtering by role and search query
|
||||||
|
_verify_user_pagination(
|
||||||
|
users=searchable_curators,
|
||||||
|
role_filter=[UserRole.GLOBAL_CURATOR],
|
||||||
|
search_query="search",
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify filtering by role and status and search query
|
||||||
|
_verify_user_pagination(
|
||||||
|
users=inactive_admins,
|
||||||
|
role_filter=[UserRole.ADMIN],
|
||||||
|
is_active_filter=False,
|
||||||
|
search_query="inactive_ad",
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify filtering by multiple roles (admin and global curator)
|
||||||
|
_verify_user_pagination(
|
||||||
|
users=admin_users + global_curators + inactive_admins + searchable_curators,
|
||||||
|
role_filter=[UserRole.ADMIN, UserRole.GLOBAL_CURATOR],
|
||||||
|
user_performing_action=user_performing_action,
|
||||||
|
)
|
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableHead,
|
TableHead,
|
||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import Text from "@/components/ui/text";
|
import Text from "@/components/ui/text";
|
||||||
import { Callout } from "@/components/ui/callout";
|
import { Callout } from "@/components/ui/callout";
|
||||||
import { CCPairFullInfo, PaginatedIndexAttempts } from "./types";
|
import { CCPairFullInfo } from "./types";
|
||||||
|
import { IndexAttemptSnapshot } from "@/lib/types";
|
||||||
import { IndexAttemptStatus } from "@/components/Status";
|
import { IndexAttemptStatus } from "@/components/Status";
|
||||||
import { PageSelector } from "@/components/PageSelector";
|
import { PageSelector } from "@/components/PageSelector";
|
||||||
import { ThreeDotsLoader } from "@/components/Loading";
|
import { ThreeDotsLoader } from "@/components/Loading";
|
||||||
@@ -22,191 +23,49 @@ import { ErrorCallout } from "@/components/ErrorCallout";
|
|||||||
import { InfoIcon, SearchIcon } from "@/components/icons/icons";
|
import { InfoIcon, SearchIcon } from "@/components/icons/icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
|
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { FiInfo } from "react-icons/fi";
|
import usePaginatedFetch from "@/hooks/usePaginatedFetch";
|
||||||
|
|
||||||
// This is the number of index attempts to display per page
|
const ITEMS_PER_PAGE = 8;
|
||||||
const NUM_IN_PAGE = 8;
|
const PAGES_PER_BATCH = 8;
|
||||||
// This is the number of pages to fetch at a time
|
|
||||||
const BATCH_SIZE = 8;
|
|
||||||
|
|
||||||
export function IndexingAttemptsTable({ ccPair }: { ccPair: CCPairFullInfo }) {
|
export function IndexingAttemptsTable({ ccPair }: { ccPair: CCPairFullInfo }) {
|
||||||
const [indexAttemptTracePopupId, setIndexAttemptTracePopupId] = useState<
|
const [indexAttemptTracePopupId, setIndexAttemptTracePopupId] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const totalPages = Math.ceil(ccPair.number_of_index_attempts / NUM_IN_PAGE);
|
const {
|
||||||
|
currentPageData: pageOfIndexAttempts,
|
||||||
const router = useRouter();
|
isLoading,
|
||||||
const [page, setPage] = useState(() => {
|
error,
|
||||||
if (typeof window !== "undefined") {
|
currentPage,
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
totalPages,
|
||||||
return parseInt(urlParams.get("page") || "1", 10);
|
goToPage,
|
||||||
}
|
} = usePaginatedFetch<IndexAttemptSnapshot>({
|
||||||
return 1;
|
itemsPerPage: ITEMS_PER_PAGE,
|
||||||
|
pagesPerBatch: PAGES_PER_BATCH,
|
||||||
|
endpoint: `${buildCCPairInfoUrl(ccPair.id)}/index-attempts`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [currentPageData, setCurrentPageData] =
|
if (isLoading || !pageOfIndexAttempts) {
|
||||||
useState<PaginatedIndexAttempts | null>(null);
|
|
||||||
const [currentPageError, setCurrentPageError] = useState<Error | null>(null);
|
|
||||||
const [isCurrentPageLoading, setIsCurrentPageLoading] = useState(false);
|
|
||||||
|
|
||||||
// This is a cache of the data for each "batch" which is a set of pages
|
|
||||||
const [cachedBatches, setCachedBatches] = useState<{
|
|
||||||
[key: number]: PaginatedIndexAttempts[];
|
|
||||||
}>({});
|
|
||||||
|
|
||||||
// This is a set of the batches that are currently being fetched
|
|
||||||
// we use it to avoid duplicate requests
|
|
||||||
const ongoingRequestsRef = useRef<Set<number>>(new Set());
|
|
||||||
|
|
||||||
const batchRetrievalUrlBuilder = useCallback(
|
|
||||||
(batchNum: number) => {
|
|
||||||
return `${buildCCPairInfoUrl(
|
|
||||||
ccPair.id
|
|
||||||
)}/index-attempts?page=${batchNum}&page_size=${BATCH_SIZE * NUM_IN_PAGE}`;
|
|
||||||
},
|
|
||||||
[ccPair.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
// This fetches and caches the data for a given batch number
|
|
||||||
const fetchBatchData = useCallback(
|
|
||||||
async (batchNum: number) => {
|
|
||||||
if (ongoingRequestsRef.current.has(batchNum)) return;
|
|
||||||
ongoingRequestsRef.current.add(batchNum);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(batchRetrievalUrlBuilder(batchNum + 1));
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch data");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const newBatchData: PaginatedIndexAttempts[] = [];
|
|
||||||
for (let i = 0; i < BATCH_SIZE; i++) {
|
|
||||||
const startIndex = i * NUM_IN_PAGE;
|
|
||||||
const endIndex = startIndex + NUM_IN_PAGE;
|
|
||||||
const pageIndexAttempts = data.index_attempts.slice(
|
|
||||||
startIndex,
|
|
||||||
endIndex
|
|
||||||
);
|
|
||||||
newBatchData.push({
|
|
||||||
...data,
|
|
||||||
index_attempts: pageIndexAttempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setCachedBatches((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[batchNum]: newBatchData,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
setCurrentPageError(
|
|
||||||
error instanceof Error ? error : new Error("An error occurred")
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
ongoingRequestsRef.current.delete(batchNum);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
ongoingRequestsRef,
|
|
||||||
setCachedBatches,
|
|
||||||
setCurrentPageError,
|
|
||||||
batchRetrievalUrlBuilder,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// This fetches and caches the data for the current batch and the next and previous batches
|
|
||||||
useEffect(() => {
|
|
||||||
const batchNum = Math.floor((page - 1) / BATCH_SIZE);
|
|
||||||
|
|
||||||
if (!cachedBatches[batchNum]) {
|
|
||||||
setIsCurrentPageLoading(true);
|
|
||||||
fetchBatchData(batchNum);
|
|
||||||
} else {
|
|
||||||
setIsCurrentPageLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextBatchNum = Math.max(
|
|
||||||
Math.min(batchNum + 1, Math.ceil(totalPages / BATCH_SIZE) - 1),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
if (!cachedBatches[nextBatchNum]) {
|
|
||||||
fetchBatchData(nextBatchNum);
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevBatchNum = Math.max(batchNum - 1, 0);
|
|
||||||
if (!cachedBatches[prevBatchNum]) {
|
|
||||||
fetchBatchData(prevBatchNum);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always fetch the first batch if it's not cached
|
|
||||||
if (!cachedBatches[0]) {
|
|
||||||
fetchBatchData(0);
|
|
||||||
}
|
|
||||||
}, [ccPair.id, page, cachedBatches, totalPages, fetchBatchData]);
|
|
||||||
|
|
||||||
// This updates the data on the current page
|
|
||||||
useEffect(() => {
|
|
||||||
const batchNum = Math.floor((page - 1) / BATCH_SIZE);
|
|
||||||
const batchPageNum = (page - 1) % BATCH_SIZE;
|
|
||||||
|
|
||||||
if (cachedBatches[batchNum] && cachedBatches[batchNum][batchPageNum]) {
|
|
||||||
setCurrentPageData(cachedBatches[batchNum][batchPageNum]);
|
|
||||||
setIsCurrentPageLoading(false);
|
|
||||||
} else {
|
|
||||||
setIsCurrentPageLoading(true);
|
|
||||||
}
|
|
||||||
}, [page, cachedBatches]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const batchNum = Math.floor((page - 1) / BATCH_SIZE);
|
|
||||||
fetchBatchData(batchNum); // Re-fetch the current batch data
|
|
||||||
}, 5000); // Refresh every 5 seconds
|
|
||||||
|
|
||||||
return () => clearInterval(interval); // Cleanup on unmount
|
|
||||||
}, [page, fetchBatchData]); // Dependencies to ensure correct batch is fetched
|
|
||||||
|
|
||||||
// This updates the page number and manages the URL
|
|
||||||
const updatePage = (newPage: number) => {
|
|
||||||
setPage(newPage);
|
|
||||||
router.replace(`/admin/connector/${ccPair.id}?page=${newPage}`, {
|
|
||||||
scroll: false,
|
|
||||||
});
|
|
||||||
window.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isCurrentPageLoading || !currentPageData) {
|
|
||||||
return <ThreeDotsLoader />;
|
return <ThreeDotsLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentPageError) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<ErrorCallout
|
<ErrorCallout
|
||||||
errorTitle={`Failed to fetch info on Connector with ID ${ccPair.id}`}
|
errorTitle={`Failed to fetch info on Connector with ID ${ccPair.id}`}
|
||||||
errorMsg={currentPageError?.toString() || "Unknown error"}
|
errorMsg={error?.toString() || "Unknown error"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if no indexing attempts have been scheduled yet, let the user know why
|
if (!pageOfIndexAttempts?.length) {
|
||||||
if (
|
|
||||||
Object.keys(cachedBatches).length === 0 ||
|
|
||||||
Object.values(cachedBatches).every((batch) =>
|
|
||||||
batch.every((page) => page.index_attempts.length === 0)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<Callout
|
<Callout
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
@@ -219,40 +78,38 @@ export function IndexingAttemptsTable({ ccPair }: { ccPair: CCPairFullInfo }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is the index attempt that the user wants to view the trace for
|
const indexAttemptToDisplayTraceFor = pageOfIndexAttempts?.find(
|
||||||
const indexAttemptToDisplayTraceFor = currentPageData?.index_attempts?.find(
|
|
||||||
(indexAttempt) => indexAttempt.id === indexAttemptTracePopupId
|
(indexAttempt) => indexAttempt.id === indexAttemptTracePopupId
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{indexAttemptToDisplayTraceFor &&
|
{indexAttemptToDisplayTraceFor?.full_exception_trace && (
|
||||||
indexAttemptToDisplayTraceFor.full_exception_trace && (
|
<ExceptionTraceModal
|
||||||
<ExceptionTraceModal
|
onOutsideClick={() => setIndexAttemptTracePopupId(null)}
|
||||||
onOutsideClick={() => setIndexAttemptTracePopupId(null)}
|
exceptionTrace={indexAttemptToDisplayTraceFor.full_exception_trace}
|
||||||
exceptionTrace={indexAttemptToDisplayTraceFor.full_exception_trace!}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Time Started</TableHead>
|
<TableHead>Time Started</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>New Documents</TableHead>
|
<TableHead>New Doc Cnt</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<div className="w-fit">
|
<div className="w-fit">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="cursor-help flex items-center">
|
<span className="cursor-help flex items-center">
|
||||||
New + Modified Documents
|
Total Doc Cnt
|
||||||
<InfoIcon className="ml-1 w-4 h-4" />
|
<InfoIcon className="ml-1 w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
Total number of documents inserted or updated in the index
|
Total number of documents replaced in the index during
|
||||||
during this indexing attempt
|
this indexing attempt
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -262,7 +119,7 @@ export function IndexingAttemptsTable({ ccPair }: { ccPair: CCPairFullInfo }) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{currentPageData.index_attempts.map((indexAttempt) => {
|
{pageOfIndexAttempts.map((indexAttempt) => {
|
||||||
const docsPerMinute =
|
const docsPerMinute =
|
||||||
getDocsProcessedPerMinute(indexAttempt)?.toFixed(2);
|
getDocsProcessedPerMinute(indexAttempt)?.toFixed(2);
|
||||||
return (
|
return (
|
||||||
@@ -322,8 +179,7 @@ export function IndexingAttemptsTable({ ccPair }: { ccPair: CCPairFullInfo }) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(indexAttempt.status === "failed" ||
|
{indexAttempt.status === "failed" &&
|
||||||
indexAttempt.status === "canceled") &&
|
|
||||||
indexAttempt.error_msg && (
|
indexAttempt.error_msg && (
|
||||||
<Text className="flex flex-wrap whitespace-normal">
|
<Text className="flex flex-wrap whitespace-normal">
|
||||||
{indexAttempt.error_msg}
|
{indexAttempt.error_msg}
|
||||||
@@ -352,8 +208,8 @@ export function IndexingAttemptsTable({ ccPair }: { ccPair: CCPairFullInfo }) {
|
|||||||
<div className="mx-auto">
|
<div className="mx-auto">
|
||||||
<PageSelector
|
<PageSelector
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
currentPage={page}
|
currentPage={currentPage}
|
||||||
onPageChange={updatePage}
|
onPageChange={goToPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -176,7 +176,10 @@ export const GenericTokenRateLimitTable = ({
|
|||||||
responseMapper?: (data: any) => TokenRateLimitDisplay[];
|
responseMapper?: (data: any) => TokenRateLimitDisplay[];
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, error } = useSWR(fetchUrl, errorHandlingFetcher);
|
const { data, isLoading, error } = useSWR<TokenRateLimitDisplay[]>(
|
||||||
|
fetchUrl,
|
||||||
|
errorHandlingFetcher
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ThreeDotsLoader />;
|
return <ThreeDotsLoader />;
|
||||||
@@ -193,7 +196,7 @@ export const GenericTokenRateLimitTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TokenRateLimitTable
|
<TokenRateLimitTable
|
||||||
tokenRateLimits={processedData}
|
tokenRateLimits={processedData ?? []}
|
||||||
fetchUrl={fetchUrl}
|
fetchUrl={fetchUrl}
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -8,7 +8,7 @@ import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
|
|||||||
import { SearchBar } from "@/components/search/SearchBar";
|
import { SearchBar } from "@/components/search/SearchBar";
|
||||||
import { FiPlusSquare } from "react-icons/fi";
|
import { FiPlusSquare } from "react-icons/fi";
|
||||||
import { Modal } from "@/components/Modal";
|
import { Modal } from "@/components/Modal";
|
||||||
import { LoadingAnimation } from "@/components/Loading";
|
import { ThreeDotsLoader } from "@/components/Loading";
|
||||||
import { AdminPageTitle } from "@/components/admin/Title";
|
import { AdminPageTitle } from "@/components/admin/Title";
|
||||||
import { usePopup, PopupSpec } from "@/components/admin/connectors/Popup";
|
import { usePopup, PopupSpec } from "@/components/admin/connectors/Popup";
|
||||||
import { UsersIcon } from "@/components/icons/icons";
|
import { UsersIcon } from "@/components/icons/icons";
|
||||||
@@ -16,9 +16,8 @@ import { errorHandlingFetcher } from "@/lib/fetcher";
|
|||||||
import useSWR, { mutate } from "swr";
|
import useSWR, { mutate } from "swr";
|
||||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||||
import BulkAdd from "@/components/admin/users/BulkAdd";
|
import BulkAdd from "@/components/admin/users/BulkAdd";
|
||||||
import { UsersResponse } from "@/lib/users/interfaces";
|
|
||||||
import SlackUserTable from "@/components/admin/users/SlackUserTable";
|
|
||||||
import Text from "@/components/ui/text";
|
import Text from "@/components/ui/text";
|
||||||
|
import { InvitedUserSnapshot } from "@/lib/types";
|
||||||
|
|
||||||
const UsersTables = ({
|
const UsersTables = ({
|
||||||
q,
|
q,
|
||||||
@@ -27,21 +26,13 @@ const UsersTables = ({
|
|||||||
q: string;
|
q: string;
|
||||||
setPopup: (spec: PopupSpec) => void;
|
setPopup: (spec: PopupSpec) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [invitedPage, setInvitedPage] = useState(1);
|
const {
|
||||||
const [acceptedPage, setAcceptedPage] = useState(1);
|
data: invitedUsers,
|
||||||
const [slackUsersPage, setSlackUsersPage] = useState(1);
|
error: invitedUsersError,
|
||||||
|
isLoading: invitedUsersLoading,
|
||||||
const [usersData, setUsersData] = useState<UsersResponse | undefined>(
|
mutate: invitedUsersMutate,
|
||||||
undefined
|
} = useSWR<InvitedUserSnapshot[]>(
|
||||||
);
|
"/api/manage/users/invited",
|
||||||
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}&slack_users_page=${slackUsersPage - 1}`,
|
|
||||||
errorHandlingFetcher
|
errorHandlingFetcher
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,33 +41,9 @@ const UsersTables = ({
|
|||||||
errorHandlingFetcher
|
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
|
// Show loading animation only during the initial data fetch
|
||||||
if (!activeData || !activeDomains) {
|
if (!validDomains) {
|
||||||
return <LoadingAnimation text="Loading" />;
|
return <ThreeDotsLoader />;
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<ErrorCallout
|
|
||||||
errorTitle="Error loading users"
|
|
||||||
errorMsg={error?.info?.detail}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domainsError) {
|
if (domainsError) {
|
||||||
@@ -88,90 +55,43 @@ const UsersTables = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
|
||||||
accepted,
|
|
||||||
invited,
|
|
||||||
accepted_pages,
|
|
||||||
invited_pages,
|
|
||||||
slack_users,
|
|
||||||
slack_users_pages,
|
|
||||||
} = activeData;
|
|
||||||
|
|
||||||
const finalInvited = invited.filter(
|
|
||||||
(user) => !accepted.some((u) => u.email === user.email)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="invited">
|
<Tabs defaultValue="current">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="invited">Invited Users</TabsTrigger>
|
|
||||||
<TabsTrigger value="current">Current Users</TabsTrigger>
|
<TabsTrigger value="current">Current Users</TabsTrigger>
|
||||||
<TabsTrigger value="onyxbot">OnyxBot Users</TabsTrigger>
|
<TabsTrigger value="invited">Invited Users</TabsTrigger>
|
||||||
</TabsList>
|
</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">
|
<TabsContent value="current">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Current Users</CardTitle>
|
<CardTitle>Current Users</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{accepted.length > 0 ? (
|
<SignedUpUserTable
|
||||||
<SignedUpUserTable
|
invitedUsers={invitedUsers || []}
|
||||||
users={accepted}
|
setPopup={setPopup}
|
||||||
setPopup={setPopup}
|
q={q}
|
||||||
currentPage={acceptedPage}
|
invitedUsersMutate={invitedUsersMutate}
|
||||||
onPageChange={setAcceptedPage}
|
/>
|
||||||
totalPages={accepted_pages}
|
|
||||||
mutate={mutate}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p>Users that have an account will show up here</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="onyxbot">
|
<TabsContent value="invited">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>OnyxBot Users</CardTitle>
|
<CardTitle>Invited Users</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{slack_users.length > 0 ? (
|
<InvitedUserTable
|
||||||
<SlackUserTable
|
users={invitedUsers || []}
|
||||||
setPopup={setPopup}
|
setPopup={setPopup}
|
||||||
currentPage={slackUsersPage}
|
mutate={invitedUsersMutate}
|
||||||
onPageChange={setSlackUsersPage}
|
error={invitedUsersError}
|
||||||
totalPages={slack_users_pages}
|
isLoading={invitedUsersLoading}
|
||||||
invitedUsers={finalInvited}
|
q={q}
|
||||||
slackusers={slack_users}
|
/>
|
||||||
mutate={mutate}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p>Slack-only users will show up here</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -187,7 +107,6 @@ const SearchableTables = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{popup}
|
{popup}
|
||||||
|
|
||||||
<div className="flex flex-col gap-y-4">
|
<div className="flex flex-col gap-y-4">
|
||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4">
|
||||||
<AddUserButton setPopup={setPopup} />
|
<AddUserButton setPopup={setPopup} />
|
||||||
@@ -257,7 +176,6 @@ const Page = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto container">
|
<div className="mx-auto container">
|
||||||
<AdminPageTitle title="Manage Users" icon={<UsersIcon size={32} />} />
|
<AdminPageTitle title="Manage Users" icon={<UsersIcon size={32} />} />
|
||||||
|
|
||||||
<SearchableTables />
|
<SearchableTables />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -15,7 +15,6 @@ import { AdminPageTitle } from "@/components/admin/Title";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import { useUser } from "@/components/user/UserProvider";
|
import { useUser } from "@/components/user/UserProvider";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
|
|
||||||
const Main = () => {
|
const Main = () => {
|
||||||
const { popup, setPopup } = usePopup();
|
const { popup, setPopup } = usePopup();
|
||||||
|
@@ -7,7 +7,7 @@ export interface PopupSpec {
|
|||||||
|
|
||||||
export const Popup: React.FC<PopupSpec> = ({ message, type }) => (
|
export const Popup: React.FC<PopupSpec> = ({ message, type }) => (
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-4 left-4 p-4 rounded-md shadow-lg text-white z-[100] ${
|
className={`fixed bottom-4 left-4 p-4 rounded-md shadow-lg text-white z-[10000] ${
|
||||||
type === "success" ? "bg-green-500" : "bg-error"
|
type === "success" ? "bg-green-500" : "bg-error"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { withFormik, FormikProps, FormikErrors, Form, Field } from "formik";
|
import { withFormik, FormikProps, FormikErrors, Form, Field } from "formik";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const WHITESPACE_SPLIT = /\s+/;
|
const WHITESPACE_SPLIT = /\s+/;
|
||||||
@@ -30,9 +29,21 @@ const AddUserFormRenderer = ({
|
|||||||
touched,
|
touched,
|
||||||
errors,
|
errors,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
handleSubmit,
|
||||||
}: FormikProps<FormValues>) => (
|
}: FormikProps<FormValues>) => (
|
||||||
<Form className="w-full">
|
<Form className="w-full" onSubmit={handleSubmit}>
|
||||||
<Field id="emails" name="emails" as="textarea" className="w-full p-4" />
|
<Field
|
||||||
|
id="emails"
|
||||||
|
name="emails"
|
||||||
|
as="textarea"
|
||||||
|
className="w-full p-4"
|
||||||
|
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{touched.emails && errors.emails && (
|
{touched.emails && errors.emails && (
|
||||||
<div className="text-error text-sm">{errors.emails}</div>
|
<div className="text-error text-sm">{errors.emails}</div>
|
||||||
)}
|
)}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -6,29 +7,63 @@ import {
|
|||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
import CenteredPageSelector from "./CenteredPageSelector";
|
import CenteredPageSelector from "./CenteredPageSelector";
|
||||||
import { type PageSelectorProps } from "@/components/PageSelector";
|
import { ThreeDotsLoader } from "@/components/Loading";
|
||||||
|
import { InvitedUserSnapshot } from "@/lib/types";
|
||||||
import { type User } from "@/lib/types";
|
|
||||||
import { TableHeader } from "@/components/ui/table";
|
import { TableHeader } from "@/components/ui/table";
|
||||||
import { InviteUserButton } from "./buttons/InviteUserButton";
|
import { InviteUserButton } from "./buttons/InviteUserButton";
|
||||||
|
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||||
|
import { FetchError } from "@/lib/fetcher";
|
||||||
|
|
||||||
|
const USERS_PER_PAGE = 10;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
users: Array<User>;
|
users: InvitedUserSnapshot[];
|
||||||
setPopup: (spec: PopupSpec) => void;
|
setPopup: (spec: PopupSpec) => void;
|
||||||
mutate: () => void;
|
mutate: () => void;
|
||||||
|
error: FetchError | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
q: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InvitedUserTable = ({
|
const InvitedUserTable = ({
|
||||||
users,
|
users,
|
||||||
setPopup,
|
setPopup,
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
onPageChange,
|
|
||||||
mutate,
|
mutate,
|
||||||
}: Props & PageSelectorProps) => {
|
error,
|
||||||
if (!users.length) return null;
|
isLoading,
|
||||||
|
q,
|
||||||
|
}: Props) => {
|
||||||
|
const [currentPageNum, setCurrentPageNum] = useState<number>(1);
|
||||||
|
|
||||||
|
if (!users.length)
|
||||||
|
return <p>Users that have been invited will show up here</p>;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(users.length / USERS_PER_PAGE);
|
||||||
|
|
||||||
|
// Filter users based on the search query
|
||||||
|
const filteredUsers = q
|
||||||
|
? users.filter((user) => user.email.includes(q))
|
||||||
|
: users;
|
||||||
|
|
||||||
|
// Get the current page of users
|
||||||
|
const currentPageOfUsers = filteredUsers.slice(
|
||||||
|
(currentPageNum - 1) * USERS_PER_PAGE,
|
||||||
|
currentPageNum * USERS_PER_PAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ThreeDotsLoader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorCallout
|
||||||
|
errorTitle="Error loading users"
|
||||||
|
errorMsg={error?.info?.detail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -42,28 +77,36 @@ const InvitedUserTable = ({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((user) => (
|
{currentPageOfUsers.length ? (
|
||||||
<TableRow key={user.email}>
|
currentPageOfUsers.map((user) => (
|
||||||
<TableCell>{user.email}</TableCell>
|
<TableRow key={user.email}>
|
||||||
<TableCell>
|
<TableCell>{user.email}</TableCell>
|
||||||
<div className="flex justify-end">
|
<TableCell>
|
||||||
<InviteUserButton
|
<div className="flex justify-end">
|
||||||
user={user}
|
<InviteUserButton
|
||||||
invited={true}
|
user={user}
|
||||||
setPopup={setPopup}
|
invited={true}
|
||||||
mutate={mutate}
|
setPopup={setPopup}
|
||||||
/>
|
mutate={mutate}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} className="h-24 text-center">
|
||||||
|
{`No users found matching "${q}"`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{totalPages > 1 ? (
|
{totalPages > 1 ? (
|
||||||
<CenteredPageSelector
|
<CenteredPageSelector
|
||||||
currentPage={currentPage}
|
currentPage={currentPageNum}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={onPageChange}
|
onPageChange={setCurrentPageNum}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
@@ -1,6 +1,11 @@
|
|||||||
import { type User, UserStatus, UserRole } from "@/lib/types";
|
import {
|
||||||
|
type User,
|
||||||
|
UserRole,
|
||||||
|
InvitedUserSnapshot,
|
||||||
|
USER_ROLE_LABELS,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { useState } from "react";
|
||||||
import CenteredPageSelector from "./CenteredPageSelector";
|
import CenteredPageSelector from "./CenteredPageSelector";
|
||||||
import { type PageSelectorProps } from "@/components/PageSelector";
|
|
||||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -10,33 +15,76 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { TableHeader } from "@/components/ui/table";
|
import { TableHeader } from "@/components/ui/table";
|
||||||
import { UserRoleDropdown } from "./buttons/UserRoleDropdown";
|
import UserRoleDropdown from "./buttons/UserRoleDropdown";
|
||||||
import { DeleteUserButton } from "./buttons/DeleteUserButton";
|
import DeleteUserButton from "./buttons/DeleteUserButton";
|
||||||
import { DeactivaterButton } from "./buttons/DeactivaterButton";
|
import DeactivateUserButton from "./buttons/DeactivateUserButton";
|
||||||
|
import usePaginatedFetch from "@/hooks/usePaginatedFetch";
|
||||||
|
import { ThreeDotsLoader } from "@/components/Loading";
|
||||||
|
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||||
|
import { InviteUserButton } from "./buttons/InviteUserButton";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const PAGES_PER_BATCH = 2;
|
||||||
import { useUser } from "@/components/user/UserProvider";
|
import { useUser } from "@/components/user/UserProvider";
|
||||||
import { LeaveOrganizationButton } from "./buttons/LeaveOrganizationButton";
|
import { LeaveOrganizationButton } from "./buttons/LeaveOrganizationButton";
|
||||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
users: Array<User>;
|
invitedUsers: InvitedUserSnapshot[];
|
||||||
setPopup: (spec: PopupSpec) => void;
|
setPopup: (spec: PopupSpec) => void;
|
||||||
mutate: () => void;
|
q: string;
|
||||||
|
invitedUsersMutate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SignedUpUserTable = ({
|
const SignedUpUserTable = ({
|
||||||
users,
|
invitedUsers,
|
||||||
setPopup,
|
setPopup,
|
||||||
currentPage,
|
q = "",
|
||||||
totalPages,
|
invitedUsersMutate,
|
||||||
onPageChange,
|
}: Props) => {
|
||||||
mutate,
|
const [filters, setFilters] = useState<{
|
||||||
}: Props & PageSelectorProps) => {
|
is_active?: boolean;
|
||||||
|
roles?: UserRole[];
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<UserRole[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentPageData: pageOfUsers,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
goToPage,
|
||||||
|
refresh,
|
||||||
|
} = usePaginatedFetch<User>({
|
||||||
|
itemsPerPage: ITEMS_PER_PAGE,
|
||||||
|
pagesPerBatch: PAGES_PER_BATCH,
|
||||||
|
endpoint: "/api/manage/users/accepted",
|
||||||
|
query: q,
|
||||||
|
filter: filters,
|
||||||
|
});
|
||||||
|
|
||||||
const { user: currentUser } = useUser();
|
const { user: currentUser } = useUser();
|
||||||
|
|
||||||
if (!users.length) return null;
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorCallout
|
||||||
|
errorTitle="Error loading users"
|
||||||
|
errorMsg={error?.message}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handlePopup = (message: string, type: "success" | "error") => {
|
const handlePopup = (message: string, type: "success" | "error") => {
|
||||||
if (type === "success") mutate();
|
if (type === "success") refresh();
|
||||||
setPopup({ message, type });
|
setPopup({ message, type });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,15 +93,149 @@ const SignedUpUserTable = ({
|
|||||||
const onRoleChangeError = (errorMsg: string) =>
|
const onRoleChangeError = (errorMsg: string) =>
|
||||||
handlePopup(`Unable to update user role - ${errorMsg}`, "error");
|
handlePopup(`Unable to update user role - ${errorMsg}`, "error");
|
||||||
|
|
||||||
|
const toggleRole = (roleEnum: UserRole) => {
|
||||||
|
setFilters((prev) => {
|
||||||
|
const currentRoles = prev.roles || [];
|
||||||
|
const newRoles = currentRoles.includes(roleEnum)
|
||||||
|
? currentRoles.filter((r) => r !== roleEnum) // Remove role if already selected
|
||||||
|
: [...currentRoles, roleEnum]; // Add role if not selected
|
||||||
|
|
||||||
|
setSelectedRoles(newRoles); // Update selected roles state
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
roles: newRoles,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRole = (roleEnum: UserRole) => {
|
||||||
|
setSelectedRoles((prev) => prev.filter((role) => role !== roleEnum)); // Remove role from selected roles
|
||||||
|
toggleRole(roleEnum); // Deselect the role in filters
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------
|
||||||
|
// Render Functions
|
||||||
|
// --------------
|
||||||
|
|
||||||
|
const renderFilters = () => (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-4 py-4">
|
||||||
|
<Select
|
||||||
|
value={filters.is_active?.toString() || "all"}
|
||||||
|
onValueChange={(selectedStatus) =>
|
||||||
|
setFilters((prev) => {
|
||||||
|
if (selectedStatus === "all") {
|
||||||
|
const { is_active, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
is_active: selectedStatus === "true",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[260px] h-[34px] bg-neutral">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-neutral-50">
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="true">Active</SelectItem>
|
||||||
|
<SelectItem value="false">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value="roles">
|
||||||
|
<SelectTrigger className="w-[260px] h-[34px] bg-neutral">
|
||||||
|
<SelectValue>
|
||||||
|
{filters.roles?.length
|
||||||
|
? `${filters.roles.length} role(s) selected`
|
||||||
|
: "All Roles"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-neutral-50">
|
||||||
|
{Object.entries(USER_ROLE_LABELS)
|
||||||
|
.filter(([role]) => role !== UserRole.EXT_PERM_USER)
|
||||||
|
.map(([role, label]) => (
|
||||||
|
<div
|
||||||
|
key={role}
|
||||||
|
className="flex items-center space-x-2 px-2 py-1.5 cursor-pointer hover:bg-gray-200"
|
||||||
|
onClick={() => toggleRole(role as UserRole)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.roles?.includes(role as UserRole) || false}
|
||||||
|
onChange={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<label className="text-sm font-normal">{label}</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 py-1">
|
||||||
|
{selectedRoles.map((role) => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
className="border border-neutral-300 bg-neutral p-1 rounded text-sm hover:bg-neutral-200"
|
||||||
|
onClick={() => removeRole(role)}
|
||||||
|
style={{ padding: "2px 8px" }}
|
||||||
|
>
|
||||||
|
<span>{USER_ROLE_LABELS[role]}</span>
|
||||||
|
<span className="ml-3">×</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderUserRoleDropdown = (user: User) => {
|
||||||
|
if (user.role === UserRole.SLACK_USER) {
|
||||||
|
return <p className="ml-2">Slack User</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<UserRoleDropdown
|
||||||
|
user={user}
|
||||||
|
onSuccess={onRoleChangeSuccess}
|
||||||
|
onError={onRoleChangeError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActionButtons = (user: User) => {
|
||||||
|
if (user.role === UserRole.SLACK_USER) {
|
||||||
|
return (
|
||||||
|
<InviteUserButton
|
||||||
|
user={user}
|
||||||
|
invited={invitedUsers.map((u) => u.email).includes(user.email)}
|
||||||
|
setPopup={setPopup}
|
||||||
|
mutate={[refresh, invitedUsersMutate]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NEXT_PUBLIC_CLOUD_ENABLED && user.id === currentUser?.id ? (
|
||||||
|
<LeaveOrganizationButton
|
||||||
|
user={user}
|
||||||
|
setPopup={setPopup}
|
||||||
|
mutate={refresh}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DeactivateUserButton
|
||||||
|
user={user}
|
||||||
|
deactivate={user.is_active}
|
||||||
|
setPopup={setPopup}
|
||||||
|
mutate={refresh}
|
||||||
|
/>
|
||||||
|
{!user.is_active && (
|
||||||
|
<DeleteUserButton user={user} setPopup={setPopup} mutate={refresh} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{totalPages > 1 ? (
|
{renderFilters()}
|
||||||
<CenteredPageSelector
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Table className="overflow-visible">
|
<Table className="overflow-visible">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -67,56 +249,52 @@ const SignedUpUserTable = ({
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
{isLoading ? (
|
||||||
{users
|
<TableBody>
|
||||||
// Dont want to show external permissioned users because it's scary
|
<TableRow>
|
||||||
.filter((user) => user.role !== UserRole.EXT_PERM_USER)
|
<TableCell colSpan={4} className="text-center">
|
||||||
.map((user) => (
|
<ThreeDotsLoader />
|
||||||
<TableRow key={user.id}>
|
</TableCell>
|
||||||
<TableCell>{user.email}</TableCell>
|
</TableRow>
|
||||||
<TableCell className="w-40 ">
|
</TableBody>
|
||||||
<UserRoleDropdown
|
) : (
|
||||||
user={user}
|
<TableBody>
|
||||||
onSuccess={onRoleChangeSuccess}
|
{!pageOfUsers?.length ? (
|
||||||
onError={onRoleChangeError}
|
<TableRow>
|
||||||
/>
|
<TableCell colSpan={4} className="text-center">
|
||||||
</TableCell>
|
<p className="pt-4 pb-4">
|
||||||
<TableCell className="text-center">
|
{filters.roles?.length || filters.is_active !== undefined
|
||||||
<i>{user.status === "live" ? "Active" : "Inactive"}</i>
|
? "No users found matching your filters"
|
||||||
</TableCell>
|
: `No users found matching "${q}"`}
|
||||||
<TableCell>
|
</p>
|
||||||
<div className="flex justify-end gap-x-2">
|
|
||||||
{NEXT_PUBLIC_CLOUD_ENABLED &&
|
|
||||||
user.id === currentUser?.id ? (
|
|
||||||
<LeaveOrganizationButton
|
|
||||||
user={user}
|
|
||||||
setPopup={setPopup}
|
|
||||||
mutate={mutate}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DeactivaterButton
|
|
||||||
user={user}
|
|
||||||
deactivate={user.status === UserStatus.live}
|
|
||||||
setPopup={setPopup}
|
|
||||||
mutate={mutate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{user.status == UserStatus.deactivated && (
|
|
||||||
<DeleteUserButton
|
|
||||||
user={user}
|
|
||||||
setPopup={setPopup}
|
|
||||||
mutate={mutate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
) : (
|
||||||
</TableBody>
|
pageOfUsers.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell className="w-[180px]">
|
||||||
|
{renderUserRoleDropdown(user)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center w-[140px]">
|
||||||
|
<i>{user.is_active ? "Active" : "Inactive"}</i>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right w-[200px]">
|
||||||
|
{renderActionButtons(user)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<CenteredPageSelector
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={goToPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,78 +0,0 @@
|
|||||||
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;
|
|
@@ -1,24 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
type User,
|
type User,
|
||||||
UserStatus,
|
|
||||||
UserRole,
|
UserRole,
|
||||||
USER_ROLE_LABELS,
|
USER_ROLE_LABELS,
|
||||||
INVALID_ROLE_HOVER_TEXT,
|
INVALID_ROLE_HOVER_TEXT,
|
||||||
} from "@/lib/types";
|
} 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 { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||||
import userMutationFetcher from "@/lib/admin/users/userMutationFetcher";
|
import userMutationFetcher from "@/lib/admin/users/userMutationFetcher";
|
||||||
import useSWRMutation from "swr/mutation";
|
import useSWRMutation from "swr/mutation";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -30,8 +18,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal";
|
import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||||
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
|
|
||||||
import { TableHeader } from "@/components/ui/table";
|
|
||||||
|
|
||||||
export const InviteUserButton = ({
|
export const InviteUserButton = ({
|
||||||
user,
|
user,
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import { type User } from "@/lib/types";
|
import { type User } from "@/lib/types";
|
||||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import useSWRMutation from "swr/mutation";
|
||||||
|
import userMutationFetcher from "@/lib/admin/users/userMutationFetcher";
|
||||||
|
|
||||||
export const DeactivaterButton = ({
|
const DeactivateUserButton = ({
|
||||||
user,
|
user,
|
||||||
deactivate,
|
deactivate,
|
||||||
setPopup,
|
setPopup,
|
||||||
@@ -43,3 +43,5 @@ export const DeactivaterButton = ({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default DeactivateUserButton;
|
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
|
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
|
||||||
|
|
||||||
export const DeleteUserButton = ({
|
const DeleteUserButton = ({
|
||||||
user,
|
user,
|
||||||
setPopup,
|
setPopup,
|
||||||
mutate,
|
mutate,
|
||||||
@@ -59,3 +59,5 @@ export const DeleteUserButton = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default DeleteUserButton;
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
import { type User } from "@/lib/types";
|
import {
|
||||||
|
type InvitedUserSnapshot,
|
||||||
|
type AcceptedUserSnapshot,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||||
import useSWRMutation from "swr/mutation";
|
import useSWRMutation from "swr/mutation";
|
||||||
@@ -12,10 +15,10 @@ export const InviteUserButton = ({
|
|||||||
setPopup,
|
setPopup,
|
||||||
mutate,
|
mutate,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: AcceptedUserSnapshot | InvitedUserSnapshot;
|
||||||
invited: boolean;
|
invited: boolean;
|
||||||
setPopup: (spec: PopupSpec) => void;
|
setPopup: (spec: PopupSpec) => void;
|
||||||
mutate: () => void;
|
mutate: (() => void) | (() => void)[];
|
||||||
}) => {
|
}) => {
|
||||||
const { trigger: inviteTrigger, isMutating: isInviting } = useSWRMutation(
|
const { trigger: inviteTrigger, isMutating: isInviting } = useSWRMutation(
|
||||||
"/api/manage/admin/users",
|
"/api/manage/admin/users",
|
||||||
@@ -35,17 +38,23 @@ export const InviteUserButton = ({
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowInviteModal(false);
|
setShowInviteModal(false);
|
||||||
mutate();
|
if (typeof mutate === "function") {
|
||||||
|
mutate();
|
||||||
|
} else {
|
||||||
|
mutate.forEach((fn) => fn());
|
||||||
|
}
|
||||||
setPopup({
|
setPopup({
|
||||||
message: "User invited successfully!",
|
message: "User invited successfully!",
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (errorMsg) =>
|
onError: (errorMsg) => {
|
||||||
|
setShowInviteModal(false);
|
||||||
setPopup({
|
setPopup({
|
||||||
message: `Unable to invite user - ${errorMsg}`,
|
message: `Unable to invite user - ${errorMsg}`,
|
||||||
type: "error",
|
type: "error",
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,17 +76,23 @@ export const InviteUserButton = ({
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowInviteModal(false);
|
setShowInviteModal(false);
|
||||||
mutate();
|
if (typeof mutate === "function") {
|
||||||
|
mutate();
|
||||||
|
} else {
|
||||||
|
mutate.forEach((fn) => fn());
|
||||||
|
}
|
||||||
setPopup({
|
setPopup({
|
||||||
message: "User uninvited successfully!",
|
message: "User uninvited successfully!",
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (errorMsg) =>
|
onError: (errorMsg) => {
|
||||||
|
setShowInviteModal(false);
|
||||||
setPopup({
|
setPopup({
|
||||||
message: `Unable to uninvite user - ${errorMsg}`,
|
message: `Unable to uninvite user - ${errorMsg}`,
|
||||||
type: "error",
|
type: "error",
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@@ -18,7 +18,7 @@ import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||||
|
|
||||||
export const UserRoleDropdown = ({
|
const UserRoleDropdown = ({
|
||||||
user,
|
user,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
@@ -121,3 +121,5 @@ export const UserRoleDropdown = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default UserRoleDropdown;
|
||||||
|
248
web/src/hooks/usePaginatedFetch.tsx
Normal file
248
web/src/hooks/usePaginatedFetch.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { useCallback, useEffect, useState, useRef, useMemo } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import {
|
||||||
|
IndexAttemptSnapshot,
|
||||||
|
AcceptedUserSnapshot,
|
||||||
|
InvitedUserSnapshot,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||||
|
|
||||||
|
type PaginatedType =
|
||||||
|
| IndexAttemptSnapshot
|
||||||
|
| AcceptedUserSnapshot
|
||||||
|
| InvitedUserSnapshot;
|
||||||
|
|
||||||
|
interface PaginatedApiResponse<T extends PaginatedType> {
|
||||||
|
items: T[];
|
||||||
|
total_items: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationConfig {
|
||||||
|
itemsPerPage: number;
|
||||||
|
pagesPerBatch: number;
|
||||||
|
endpoint: string;
|
||||||
|
query?: string;
|
||||||
|
filter?: Record<string, string | boolean | number | string[]>;
|
||||||
|
refreshIntervalInMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedHookReturnData<T extends PaginatedType> {
|
||||||
|
currentPageData: T[] | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
goToPage: (page: number) => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePaginatedFetch<T extends PaginatedType>({
|
||||||
|
itemsPerPage,
|
||||||
|
pagesPerBatch,
|
||||||
|
endpoint,
|
||||||
|
query,
|
||||||
|
filter,
|
||||||
|
refreshIntervalInMs = 5000,
|
||||||
|
}: PaginationConfig): PaginatedHookReturnData<T> {
|
||||||
|
const router = useRouter();
|
||||||
|
const currentPath = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// State to initialize and hold the current page number
|
||||||
|
const [currentPage, setCurrentPage] = useState(() =>
|
||||||
|
parseInt(searchParams?.get("page") || "1", 10)
|
||||||
|
);
|
||||||
|
const [currentPageData, setCurrentPageData] = useState<T[] | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [totalItems, setTotalItems] = useState<number>(0);
|
||||||
|
const [cachedBatches, setCachedBatches] = useState<{ [key: number]: T[][] }>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tracks ongoing requests to avoid duplicate requests, uses ref to persist across renders
|
||||||
|
const ongoingRequestsRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const totalPages = useMemo(() => {
|
||||||
|
if (totalItems === 0) return 1;
|
||||||
|
return Math.ceil(totalItems / itemsPerPage);
|
||||||
|
}, [totalItems, itemsPerPage]);
|
||||||
|
|
||||||
|
// Calculates which batch we're in, and which page within that batch
|
||||||
|
const batchAndPageIndices = useMemo(() => {
|
||||||
|
const batchNum = Math.floor((currentPage - 1) / pagesPerBatch);
|
||||||
|
const batchPageNum = (currentPage - 1) % pagesPerBatch;
|
||||||
|
return { batchNum, batchPageNum };
|
||||||
|
}, [currentPage, pagesPerBatch]);
|
||||||
|
|
||||||
|
// Fetches a batch of data and stores it in the cache
|
||||||
|
const fetchBatchData = useCallback(
|
||||||
|
async (batchNum: number) => {
|
||||||
|
// Prevents duplicate requests
|
||||||
|
if (ongoingRequestsRef.current.has(batchNum)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ongoingRequestsRef.current.add(batchNum);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build query params
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page_num: batchNum.toString(),
|
||||||
|
page_size: (pagesPerBatch * itemsPerPage).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query) params.set("q", query);
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((str) => params.append(key, str));
|
||||||
|
} else {
|
||||||
|
params.set(key, value.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${endpoint}?${params.toString()}`;
|
||||||
|
const responseData =
|
||||||
|
await errorHandlingFetcher<PaginatedApiResponse<T>>(url);
|
||||||
|
|
||||||
|
// Validate response data structure
|
||||||
|
if (
|
||||||
|
!Array.isArray(
|
||||||
|
responseData.items || typeof responseData.total_items !== "number"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Sorry, we encountered an issue with the data format. Please try again or contact support if the problem persists."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotalItems(responseData.total_items);
|
||||||
|
|
||||||
|
// Splits a batch into pages
|
||||||
|
const pagesInBatch = Array.from({ length: pagesPerBatch }, (_, i) => {
|
||||||
|
const startIndex = i * itemsPerPage;
|
||||||
|
return responseData.items.slice(
|
||||||
|
startIndex,
|
||||||
|
startIndex + itemsPerPage
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setCachedBatches((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[batchNum]: pagesInBatch,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
} finally {
|
||||||
|
ongoingRequestsRef.current.delete(batchNum);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[endpoint, pagesPerBatch, itemsPerPage, query, filter]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Updates the URL with the current page number
|
||||||
|
const updatePageUrl = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
if (currentPath) {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("page", page.toString());
|
||||||
|
router.replace(`${currentPath}?${params.toString()}`, {
|
||||||
|
scroll: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentPath, router, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Updates the current page
|
||||||
|
const goToPage = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
updatePageUrl(newPage);
|
||||||
|
},
|
||||||
|
[updatePageUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loads the current and adjacent batches
|
||||||
|
useEffect(() => {
|
||||||
|
const { batchNum } = batchAndPageIndices;
|
||||||
|
const nextBatchNum = batchNum + 1;
|
||||||
|
const prevBatchNum = Math.max(batchNum - 1, 0);
|
||||||
|
|
||||||
|
if (!cachedBatches[batchNum]) {
|
||||||
|
setIsLoading(true);
|
||||||
|
fetchBatchData(batchNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Possible total number of items including the next batch
|
||||||
|
const totalItemsIncludingNextBatch =
|
||||||
|
nextBatchNum * pagesPerBatch * itemsPerPage;
|
||||||
|
// Preload next batch if we're not on the last batch
|
||||||
|
if (
|
||||||
|
totalItemsIncludingNextBatch <= totalItems &&
|
||||||
|
!cachedBatches[nextBatchNum]
|
||||||
|
) {
|
||||||
|
fetchBatchData(nextBatchNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load previous batch if missing
|
||||||
|
if (!cachedBatches[prevBatchNum]) {
|
||||||
|
fetchBatchData(prevBatchNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure first batch is always loaded
|
||||||
|
if (!cachedBatches[0]) {
|
||||||
|
fetchBatchData(0);
|
||||||
|
}
|
||||||
|
}, [currentPage, cachedBatches, totalPages, pagesPerBatch, fetchBatchData]);
|
||||||
|
|
||||||
|
// Updates current page data from the cache
|
||||||
|
useEffect(() => {
|
||||||
|
const { batchNum, batchPageNum } = batchAndPageIndices;
|
||||||
|
|
||||||
|
if (cachedBatches[batchNum] && cachedBatches[batchNum][batchPageNum]) {
|
||||||
|
setCurrentPageData(cachedBatches[batchNum][batchPageNum]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentPage, cachedBatches, pagesPerBatch]);
|
||||||
|
|
||||||
|
// Implements periodic refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (!refreshIntervalInMs) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const { batchNum } = batchAndPageIndices;
|
||||||
|
fetchBatchData(batchNum);
|
||||||
|
}, refreshIntervalInMs);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [currentPage, pagesPerBatch, refreshIntervalInMs, fetchBatchData]);
|
||||||
|
|
||||||
|
// Manually refreshes the current batch
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const { batchNum } = batchAndPageIndices;
|
||||||
|
await fetchBatchData(batchNum);
|
||||||
|
}, [currentPage, pagesPerBatch, fetchBatchData]);
|
||||||
|
|
||||||
|
// Cache invalidation
|
||||||
|
useEffect(() => {
|
||||||
|
setCachedBatches({});
|
||||||
|
setTotalItems(0);
|
||||||
|
goToPage(1);
|
||||||
|
setError(null);
|
||||||
|
}, [currentPath, query, filter]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPage,
|
||||||
|
currentPageData,
|
||||||
|
totalPages,
|
||||||
|
goToPage,
|
||||||
|
refresh,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePaginatedFetch;
|
@@ -19,7 +19,7 @@ const DEFAULT_AUTH_ERROR_MSG =
|
|||||||
|
|
||||||
const DEFAULT_ERROR_MSG = "An error occurred while fetching the data.";
|
const DEFAULT_ERROR_MSG = "An error occurred while fetching the data.";
|
||||||
|
|
||||||
export const errorHandlingFetcher = async (url: string) => {
|
export const errorHandlingFetcher = async <T>(url: string): Promise<T> => {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
|
@@ -13,7 +13,7 @@ import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelect
|
|||||||
import { SourceMetadata } from "./search/interfaces";
|
import { SourceMetadata } from "./search/interfaces";
|
||||||
import { destructureValue, structureValue } from "./llm/utils";
|
import { destructureValue, structureValue } from "./llm/utils";
|
||||||
import { ChatSession } from "@/app/chat/interfaces";
|
import { ChatSession } from "@/app/chat/interfaces";
|
||||||
import { UsersResponse } from "./users/interfaces";
|
import { AllUsersResponse } from "./types";
|
||||||
import { Credential } from "./connectors/credentials";
|
import { Credential } from "./connectors/credentials";
|
||||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||||
import { PersonaCategory } from "@/app/admin/assistants/interfaces";
|
import { PersonaCategory } from "@/app/admin/assistants/interfaces";
|
||||||
@@ -145,7 +145,7 @@ export function useFilters(): FilterManager {
|
|||||||
export const useUsers = () => {
|
export const useUsers = () => {
|
||||||
const url = "/api/manage/users";
|
const url = "/api/manage/users";
|
||||||
|
|
||||||
const swrResponse = useSWR<UsersResponse>(url, errorHandlingFetcher);
|
const swrResponse = useSWR<AllUsersResponse>(url, errorHandlingFetcher);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...swrResponse,
|
...swrResponse,
|
||||||
|
@@ -12,12 +12,6 @@ interface UserPreferences {
|
|||||||
auto_scroll: boolean | null;
|
auto_scroll: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum UserStatus {
|
|
||||||
live = "live",
|
|
||||||
invited = "invited",
|
|
||||||
deactivated = "deactivated",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
LIMITED = "limited",
|
LIMITED = "limited",
|
||||||
BASIC = "basic",
|
BASIC = "basic",
|
||||||
@@ -51,12 +45,11 @@ export const INVALID_ROLE_HOVER_TEXT: Partial<Record<UserRole, string>> = {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
is_active: string;
|
is_active: boolean;
|
||||||
is_superuser: string;
|
is_superuser: boolean;
|
||||||
is_verified: string;
|
is_verified: boolean;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
status: UserStatus;
|
|
||||||
current_token_created_at?: Date;
|
current_token_created_at?: Date;
|
||||||
current_token_expiry_length?: number;
|
current_token_expiry_length?: number;
|
||||||
oidc_expiry?: Date;
|
oidc_expiry?: Date;
|
||||||
@@ -65,6 +58,26 @@ export interface User {
|
|||||||
is_anonymous_user?: boolean;
|
is_anonymous_user?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AllUsersResponse {
|
||||||
|
accepted: User[];
|
||||||
|
invited: User[];
|
||||||
|
slack_users: User[];
|
||||||
|
accepted_pages: number;
|
||||||
|
invited_pages: number;
|
||||||
|
slack_users_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptedUserSnapshot {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitedUserSnapshot {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MinimalUserSnapshot {
|
export interface MinimalUserSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
@@ -1,10 +0,0 @@
|
|||||||
import { User } from "../types";
|
|
||||||
|
|
||||||
export interface UsersResponse {
|
|
||||||
accepted: User[];
|
|
||||||
invited: User[];
|
|
||||||
slack_users: User[];
|
|
||||||
accepted_pages: number;
|
|
||||||
invited_pages: number;
|
|
||||||
slack_users_pages: number;
|
|
||||||
}
|
|
Reference in New Issue
Block a user