mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-04-08 20:08:36 +02:00
Curator role (#2166)
* Added backend support for curator role * modal refactor * finalized first 2 commits same as before finally what was it for * added credential, cc_pair, and cleanup mypy is super helpful hahahahahahahahahahahaha * curator support for personas * added connector management permission checks * fixed the connector creation flow * added document access to curator * small cleanup added comments and started ui * groups and assistant editor * Persona frontend * Document set frontend * cleaned up the entire frontend * alembic fix * Minor fixes * credentials section * some credential updates * removed logging statements * fixed try catch * fixed model name * made everything happen in one db commit * Final cleanup * cleaned up fast code * mypy/build fixes * polish * more token rate limit polish * fixed weird credential permissions * Addressed chris feedback * addressed pablo feedback * fixed alembic * removed deduping and caching * polish!!!!
This commit is contained in:
parent
5409777e0b
commit
c042a19c00
90
backend/alembic/versions/351faebd379d_add_curator_fields.py
Normal file
90
backend/alembic/versions/351faebd379d_add_curator_fields.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Add curator fields
|
||||
|
||||
Revision ID: 351faebd379d
|
||||
Revises: ee3f4b47fad5
|
||||
Create Date: 2024-08-15 22:37:08.397052
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "351faebd379d"
|
||||
down_revision = "ee3f4b47fad5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add is_curator column to User__UserGroup table
|
||||
op.add_column(
|
||||
"user__user_group",
|
||||
sa.Column("is_curator", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
# Use batch mode to modify the enum type
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.alter_column( # type: ignore[attr-defined]
|
||||
"role",
|
||||
type_=sa.Enum(
|
||||
"BASIC",
|
||||
"ADMIN",
|
||||
"CURATOR",
|
||||
"GLOBAL_CURATOR",
|
||||
name="userrole",
|
||||
native_enum=False,
|
||||
),
|
||||
existing_type=sa.Enum("BASIC", "ADMIN", name="userrole", native_enum=False),
|
||||
existing_nullable=False,
|
||||
)
|
||||
# Create the association table
|
||||
op.create_table(
|
||||
"credential__user_group",
|
||||
sa.Column("credential_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_group_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["credential_id"],
|
||||
["credential.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_group_id"],
|
||||
["user_group.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("credential_id", "user_group_id"),
|
||||
)
|
||||
op.add_column(
|
||||
"credential",
|
||||
sa.Column(
|
||||
"curator_public", sa.Boolean(), nullable=False, server_default="false"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Update existing records to ensure they fit within the BASIC/ADMIN roles
|
||||
op.execute(
|
||||
"UPDATE \"user\" SET role = 'ADMIN' WHERE role IN ('CURATOR', 'GLOBAL_CURATOR')"
|
||||
)
|
||||
|
||||
# Remove is_curator column from User__UserGroup table
|
||||
op.drop_column("user__user_group", "is_curator")
|
||||
|
||||
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||
batch_op.alter_column( # type: ignore[attr-defined]
|
||||
"role",
|
||||
type_=sa.Enum(
|
||||
"BASIC", "ADMIN", name="userrole", native_enum=False, length=20
|
||||
),
|
||||
existing_type=sa.Enum(
|
||||
"BASIC",
|
||||
"ADMIN",
|
||||
"CURATOR",
|
||||
"GLOBAL_CURATOR",
|
||||
name="userrole",
|
||||
native_enum=False,
|
||||
),
|
||||
existing_nullable=False,
|
||||
)
|
||||
# Drop the association table
|
||||
op.drop_table("credential__user_group")
|
||||
op.drop_column("credential", "curator_public")
|
@ -5,8 +5,20 @@ from fastapi_users import schemas
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""
|
||||
User roles
|
||||
- Basic can't perform any admin actions
|
||||
- Admin can perform all admin actions
|
||||
- Curator can perform admin actions for
|
||||
groups they are curators of
|
||||
- Global Curator can perform admin actions
|
||||
for all groups they are a member of
|
||||
"""
|
||||
|
||||
BASIC = "basic"
|
||||
ADMIN = "admin"
|
||||
CURATOR = "curator"
|
||||
GLOBAL_CURATOR = "global_curator"
|
||||
|
||||
|
||||
class UserStatus(str, Enum):
|
||||
|
@ -67,6 +67,23 @@ from danswer.utils.variable_functionality import fetch_versioned_implementation
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def validate_curator_request(groups: list | None, is_public: bool) -> None:
|
||||
if is_public:
|
||||
detail = "User does not have permission to create public credentials"
|
||||
logger.error(detail)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=detail,
|
||||
)
|
||||
if not groups:
|
||||
detail = "Curators must specify 1+ groups"
|
||||
logger.error(detail)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Curators must specify 1+ groups",
|
||||
)
|
||||
|
||||
|
||||
def is_user_admin(user: User | None) -> bool:
|
||||
if AUTH_TYPE == AuthType.DISABLED:
|
||||
return True
|
||||
@ -395,6 +412,28 @@ async def current_user(
|
||||
return await double_check_user(user)
|
||||
|
||||
|
||||
async def current_curator_or_admin_user(
|
||||
user: User | None = Depends(current_user),
|
||||
) -> User | None:
|
||||
if DISABLE_AUTH:
|
||||
return None
|
||||
|
||||
if not user or not hasattr(user, "role"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied. User is not authenticated or lacks role information.",
|
||||
)
|
||||
|
||||
allowed_roles = {UserRole.GLOBAL_CURATOR, UserRole.CURATOR, UserRole.ADMIN}
|
||||
if user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied. User is not a curator or admin.",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def current_admin_user(user: User | None = Depends(current_user)) -> User | None:
|
||||
if DISABLE_AUTH:
|
||||
return None
|
||||
@ -402,7 +441,7 @@ async def current_admin_user(user: User | None = Depends(current_user)) -> User
|
||||
if not user or not hasattr(user, "role") or user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied. User is not an admin.",
|
||||
detail="Access denied. User must be an admin to perform this action.",
|
||||
)
|
||||
|
||||
return user
|
||||
|
@ -146,7 +146,12 @@ def handle_regular_answer(
|
||||
if len(new_message_request.messages) > 1:
|
||||
persona = cast(
|
||||
Persona,
|
||||
fetch_persona_by_id(db_session, new_message_request.persona_id),
|
||||
fetch_persona_by_id(
|
||||
db_session,
|
||||
new_message_request.persona_id,
|
||||
user=None,
|
||||
get_editable=False,
|
||||
),
|
||||
)
|
||||
llm, _ = get_llms_for_persona(persona)
|
||||
|
||||
|
@ -75,8 +75,8 @@ def fetch_ingestion_connector_by_name(
|
||||
|
||||
|
||||
def create_connector(
|
||||
connector_data: ConnectorBase,
|
||||
db_session: Session,
|
||||
connector_data: ConnectorBase,
|
||||
) -> ObjectCreationIdResponse:
|
||||
if connector_by_name_source_exists(
|
||||
connector_data.name, connector_data.source, db_session
|
||||
@ -132,8 +132,8 @@ def update_connector(
|
||||
|
||||
|
||||
def delete_connector(
|
||||
connector_id: int,
|
||||
db_session: Session,
|
||||
connector_id: int,
|
||||
) -> StatusResponse[int]:
|
||||
"""Only used in special cases (e.g. a connector is in a bad state and we need to delete it).
|
||||
Be VERY careful using this, as it could lead to a bad state if not used correctly.
|
||||
|
@ -3,7 +3,10 @@ from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import exists
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.configs.constants import DocumentSource
|
||||
@ -16,16 +19,74 @@ from danswer.db.models import IndexAttempt
|
||||
from danswer.db.models import IndexingStatus
|
||||
from danswer.db.models import IndexModelStatus
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import User__UserGroup
|
||||
from danswer.db.models import UserGroup__ConnectorCredentialPair
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.server.models import StatusResponse
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def _add_user_filters(
|
||||
stmt: Select, user: User | None, get_editable: bool = True
|
||||
) -> Select:
|
||||
# If user is None, assume the user is an admin or auth is disabled
|
||||
if user is None or user.role == UserRole.ADMIN:
|
||||
return stmt
|
||||
|
||||
UG__CCpair = aliased(UserGroup__ConnectorCredentialPair)
|
||||
User__UG = aliased(User__UserGroup)
|
||||
|
||||
"""
|
||||
Here we select cc_pairs by relation:
|
||||
User -> User__UserGroup -> UserGroup__ConnectorCredentialPair ->
|
||||
ConnectorCredentialPair
|
||||
"""
|
||||
stmt = stmt.outerjoin(UG__CCpair).outerjoin(
|
||||
User__UG,
|
||||
User__UG.user_group_id == UG__CCpair.user_group_id,
|
||||
)
|
||||
|
||||
"""
|
||||
Filter cc_pairs by:
|
||||
- if the user is in the user_group that owns the cc_pair
|
||||
- if the user is not a global_curator, they must also have a curator relationship
|
||||
to the user_group
|
||||
- if editing is being done, we also filter out cc_pairs that are owned by groups
|
||||
that the user isn't a curator for
|
||||
- if we are not editing, we show all cc_pairs in the groups the user is a curator
|
||||
for (as well as public cc_pairs)
|
||||
"""
|
||||
where_clause = User__UG.user_id == user.id
|
||||
if user.role == UserRole.CURATOR and get_editable:
|
||||
where_clause &= User__UG.is_curator == True # noqa: E712
|
||||
if get_editable:
|
||||
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
|
||||
if user.role == UserRole.CURATOR:
|
||||
user_groups = user_groups.where(
|
||||
User__UserGroup.is_curator == True # noqa: E712
|
||||
)
|
||||
where_clause &= (
|
||||
~exists()
|
||||
.where(UG__CCpair.cc_pair_id == ConnectorCredentialPair.id)
|
||||
.where(~UG__CCpair.user_group_id.in_(user_groups))
|
||||
.correlate(ConnectorCredentialPair)
|
||||
)
|
||||
else:
|
||||
where_clause |= ConnectorCredentialPair.is_public == True # noqa: E712
|
||||
|
||||
return stmt.where(where_clause)
|
||||
|
||||
|
||||
def get_connector_credential_pairs(
|
||||
db_session: Session, include_disabled: bool = True
|
||||
db_session: Session,
|
||||
include_disabled: bool = True,
|
||||
user: User | None = None,
|
||||
get_editable: bool = True,
|
||||
) -> list[ConnectorCredentialPair]:
|
||||
stmt = select(ConnectorCredentialPair)
|
||||
stmt = _add_user_filters(stmt, user, get_editable)
|
||||
if not include_disabled:
|
||||
stmt = stmt.where(
|
||||
ConnectorCredentialPair.status == ConnectorCredentialPairStatus.ACTIVE
|
||||
@ -38,8 +99,11 @@ def get_connector_credential_pair(
|
||||
connector_id: int,
|
||||
credential_id: int,
|
||||
db_session: Session,
|
||||
user: User | None = None,
|
||||
get_editable: bool = True,
|
||||
) -> ConnectorCredentialPair | None:
|
||||
stmt = select(ConnectorCredentialPair)
|
||||
stmt = _add_user_filters(stmt, user, get_editable)
|
||||
stmt = stmt.where(ConnectorCredentialPair.connector_id == connector_id)
|
||||
stmt = stmt.where(ConnectorCredentialPair.credential_id == credential_id)
|
||||
result = db_session.execute(stmt)
|
||||
@ -49,8 +113,11 @@ def get_connector_credential_pair(
|
||||
def get_connector_credential_source_from_id(
|
||||
cc_pair_id: int,
|
||||
db_session: Session,
|
||||
user: User | None = None,
|
||||
get_editable: bool = True,
|
||||
) -> DocumentSource | None:
|
||||
stmt = select(ConnectorCredentialPair)
|
||||
stmt = _add_user_filters(stmt, user, get_editable)
|
||||
stmt = stmt.where(ConnectorCredentialPair.id == cc_pair_id)
|
||||
result = db_session.execute(stmt)
|
||||
cc_pair = result.scalar_one_or_none()
|
||||
@ -60,8 +127,11 @@ def get_connector_credential_source_from_id(
|
||||
def get_connector_credential_pair_from_id(
|
||||
cc_pair_id: int,
|
||||
db_session: Session,
|
||||
user: User | None = None,
|
||||
get_editable: bool = True,
|
||||
) -> ConnectorCredentialPair | None:
|
||||
stmt = select(ConnectorCredentialPair)
|
||||
stmt = select(ConnectorCredentialPair).distinct()
|
||||
stmt = _add_user_filters(stmt, user, get_editable)
|
||||
stmt = stmt.where(ConnectorCredentialPair.id == cc_pair_id)
|
||||
result = db_session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@ -217,14 +287,28 @@ def associate_default_cc_pair(db_session: Session) -> None:
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def _relate_groups_to_cc_pair__no_commit(
|
||||
db_session: Session,
|
||||
cc_pair_id: int,
|
||||
user_group_ids: list[int],
|
||||
) -> None:
|
||||
for group_id in user_group_ids:
|
||||
db_session.add(
|
||||
UserGroup__ConnectorCredentialPair(
|
||||
user_group_id=group_id, cc_pair_id=cc_pair_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def add_credential_to_connector(
|
||||
db_session: Session,
|
||||
user: User | None,
|
||||
connector_id: int,
|
||||
credential_id: int,
|
||||
cc_pair_name: str | None,
|
||||
is_public: bool,
|
||||
user: User | None,
|
||||
db_session: Session,
|
||||
) -> StatusResponse[int]:
|
||||
groups: list[int] | None,
|
||||
) -> StatusResponse:
|
||||
connector = fetch_connector_by_id(connector_id, db_session)
|
||||
credential = fetch_credential_by_id(credential_id, user, db_session)
|
||||
|
||||
@ -260,12 +344,21 @@ def add_credential_to_connector(
|
||||
is_public=is_public,
|
||||
)
|
||||
db_session.add(association)
|
||||
db_session.flush() # make sure the association has an id
|
||||
|
||||
if groups:
|
||||
_relate_groups_to_cc_pair__no_commit(
|
||||
db_session=db_session,
|
||||
cc_pair_id=association.id,
|
||||
user_group_ids=groups,
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
return StatusResponse(
|
||||
success=True,
|
||||
message=f"New Credential {credential_id} added to Connector",
|
||||
data=connector_id,
|
||||
success=False,
|
||||
message=f"Connector already has Credential {credential_id}",
|
||||
data=association.id,
|
||||
)
|
||||
|
||||
|
||||
@ -287,13 +380,12 @@ def remove_credential_from_connector(
|
||||
detail="Credential does not exist or does not belong to user",
|
||||
)
|
||||
|
||||
association = (
|
||||
db_session.query(ConnectorCredentialPair)
|
||||
.filter(
|
||||
ConnectorCredentialPair.connector_id == connector_id,
|
||||
ConnectorCredentialPair.credential_id == credential_id,
|
||||
)
|
||||
.one_or_none()
|
||||
association = get_connector_credential_pair(
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
get_editable=True,
|
||||
)
|
||||
|
||||
if association is not None:
|
||||
|
@ -1,5 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import exists
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update
|
||||
@ -17,8 +18,10 @@ from danswer.connectors.google_drive.constants import (
|
||||
)
|
||||
from danswer.db.models import ConnectorCredentialPair
|
||||
from danswer.db.models import Credential
|
||||
from danswer.db.models import Credential__UserGroup
|
||||
from danswer.db.models import DocumentByConnectorCredentialPair
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import User__UserGroup
|
||||
from danswer.server.documents.models import CredentialBase
|
||||
from danswer.server.documents.models import CredentialDataUpdateRequest
|
||||
from danswer.utils.logger import setup_logger
|
||||
@ -26,42 +29,122 @@ from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
# The credentials for these sources are not real so
|
||||
# permissions are not enforced for them
|
||||
CREDENTIAL_PERMISSIONS_TO_IGNORE = {
|
||||
DocumentSource.FILE,
|
||||
DocumentSource.WEB,
|
||||
DocumentSource.NOT_APPLICABLE,
|
||||
DocumentSource.GOOGLE_SITES,
|
||||
DocumentSource.WIKIPEDIA,
|
||||
DocumentSource.MEDIAWIKI,
|
||||
}
|
||||
|
||||
def _attach_user_filters(
|
||||
stmt: Select[tuple[Credential]],
|
||||
|
||||
def _add_user_filters(
|
||||
stmt: Select,
|
||||
user: User | None,
|
||||
assume_admin: bool = False, # Used with API key
|
||||
get_editable: bool = True,
|
||||
) -> Select:
|
||||
"""Attaches filters to the statement to ensure that the user can only
|
||||
access the appropriate credentials"""
|
||||
if user:
|
||||
if user.role == UserRole.ADMIN:
|
||||
if not user:
|
||||
if assume_admin:
|
||||
# apply admin filters minus the user_id check
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Credential.user_id == user.id,
|
||||
Credential.user_id.is_(None),
|
||||
Credential.admin_public == True, # noqa: E712
|
||||
Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE),
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(Credential.user_id == user.id)
|
||||
elif assume_admin:
|
||||
stmt = stmt.where(
|
||||
return stmt
|
||||
|
||||
if user.role == UserRole.ADMIN:
|
||||
# Admins can access all credentials that are public or owned by them
|
||||
# or are not associated with any user
|
||||
return stmt.where(
|
||||
or_(
|
||||
Credential.user_id == user.id,
|
||||
Credential.user_id.is_(None),
|
||||
Credential.admin_public == True, # noqa: E712
|
||||
Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE),
|
||||
)
|
||||
)
|
||||
if user.role == UserRole.BASIC:
|
||||
# Basic users can only access credentials that are owned by them
|
||||
return stmt.where(Credential.user_id == user.id)
|
||||
|
||||
return stmt
|
||||
"""
|
||||
THIS PART IS FOR CURATORS AND GLOBAL CURATORS
|
||||
Here we select cc_pairs by relation:
|
||||
User -> User__UserGroup -> Credential__UserGroup -> Credential
|
||||
"""
|
||||
stmt = stmt.outerjoin(Credential__UserGroup).outerjoin(
|
||||
User__UserGroup,
|
||||
User__UserGroup.user_group_id == Credential__UserGroup.user_group_id,
|
||||
)
|
||||
"""
|
||||
Filter Credentials by:
|
||||
- if the user is in the user_group that owns the Credential
|
||||
- if the user is not a global_curator, they must also have a curator relationship
|
||||
to the user_group
|
||||
- if editing is being done, we also filter out Credentials that are owned by groups
|
||||
that the user isn't a curator for
|
||||
- if we are not editing, we show all Credentials in the groups the user is a curator
|
||||
for (as well as public Credentials)
|
||||
- if we are not editing, we return all Credentials directly connected to the user
|
||||
"""
|
||||
where_clause = User__UserGroup.user_id == user.id
|
||||
if user.role == UserRole.CURATOR:
|
||||
where_clause &= User__UserGroup.is_curator == True # noqa: E712
|
||||
if get_editable:
|
||||
user_groups = select(User__UserGroup.user_group_id).where(
|
||||
User__UserGroup.user_id == user.id
|
||||
)
|
||||
if user.role == UserRole.CURATOR:
|
||||
user_groups = user_groups.where(
|
||||
User__UserGroup.is_curator == True # noqa: E712
|
||||
)
|
||||
where_clause &= (
|
||||
~exists()
|
||||
.where(Credential__UserGroup.credential_id == Credential.id)
|
||||
.where(~Credential__UserGroup.user_group_id.in_(user_groups))
|
||||
.correlate(Credential)
|
||||
)
|
||||
else:
|
||||
where_clause |= Credential.curator_public == True # noqa: E712
|
||||
where_clause |= Credential.user_id == user.id # noqa: E712
|
||||
|
||||
where_clause |= Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE)
|
||||
|
||||
return stmt.where(where_clause)
|
||||
|
||||
|
||||
def _relate_credential_to_user_groups__no_commit(
|
||||
db_session: Session,
|
||||
credential_id: int,
|
||||
user_group_ids: list[int],
|
||||
) -> None:
|
||||
credential_user_groups = []
|
||||
for group_id in user_group_ids:
|
||||
credential_user_groups.append(
|
||||
Credential__UserGroup(
|
||||
credential_id=credential_id,
|
||||
user_group_id=group_id,
|
||||
)
|
||||
)
|
||||
db_session.add_all(credential_user_groups)
|
||||
|
||||
|
||||
def fetch_credentials(
|
||||
db_session: Session,
|
||||
user: User | None = None,
|
||||
get_editable: bool = True,
|
||||
) -> list[Credential]:
|
||||
stmt = select(Credential)
|
||||
stmt = _attach_user_filters(stmt, user)
|
||||
stmt = _add_user_filters(stmt, user, get_editable=get_editable)
|
||||
results = db_session.scalars(stmt)
|
||||
return list(results.all())
|
||||
|
||||
@ -73,7 +156,7 @@ def fetch_credential_by_id(
|
||||
assume_admin: bool = False,
|
||||
) -> Credential | None:
|
||||
stmt = select(Credential).where(Credential.id == credential_id)
|
||||
stmt = _attach_user_filters(stmt, user, assume_admin=assume_admin)
|
||||
stmt = _add_user_filters(stmt, user, assume_admin=assume_admin)
|
||||
result = db_session.execute(stmt)
|
||||
credential = result.scalar_one_or_none()
|
||||
return credential
|
||||
@ -83,9 +166,10 @@ def fetch_credentials_by_source(
|
||||
db_session: Session,
|
||||
user: User | None,
|
||||
document_source: DocumentSource | None = None,
|
||||
get_editable: bool = True,
|
||||
) -> list[Credential]:
|
||||
base_query = select(Credential).where(Credential.source == document_source)
|
||||
base_query = _attach_user_filters(base_query, user)
|
||||
base_query = _add_user_filters(base_query, user, get_editable=get_editable)
|
||||
credentials = db_session.execute(base_query).scalars().all()
|
||||
return list(credentials)
|
||||
|
||||
@ -153,19 +237,38 @@ def create_credential(
|
||||
admin_public=credential_data.admin_public,
|
||||
source=credential_data.source,
|
||||
name=credential_data.name,
|
||||
curator_public=credential_data.curator_public,
|
||||
)
|
||||
db_session.add(credential)
|
||||
db_session.flush() # This ensures the credential gets an ID
|
||||
|
||||
_relate_credential_to_user_groups__no_commit(
|
||||
db_session=db_session,
|
||||
credential_id=credential.id,
|
||||
user_group_ids=credential_data.groups,
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
return credential
|
||||
|
||||
|
||||
def _cleanup_credential__user_group_relationships__no_commit(
|
||||
db_session: Session, credential_id: int
|
||||
) -> None:
|
||||
"""NOTE: does not commit the transaction."""
|
||||
db_session.query(Credential__UserGroup).filter(
|
||||
Credential__UserGroup.credential_id == credential_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
|
||||
def alter_credential(
|
||||
credential_id: int,
|
||||
credential_data: CredentialDataUpdateRequest,
|
||||
user: User,
|
||||
db_session: Session,
|
||||
) -> Credential | None:
|
||||
# TODO: add user group relationship update
|
||||
credential = fetch_credential_by_id(credential_id, user, db_session)
|
||||
|
||||
if credential is None:
|
||||
@ -275,6 +378,7 @@ def delete_credential(
|
||||
else:
|
||||
logger.notice(f"Deleting credential {credential_id}")
|
||||
|
||||
_cleanup_credential__user_group_relationships__no_commit(db_session, credential_id)
|
||||
db_session.delete(credential)
|
||||
db_session.commit()
|
||||
|
||||
|
@ -4,9 +4,12 @@ from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import exists
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.db.enums import ConnectorCredentialPairStatus
|
||||
@ -15,6 +18,10 @@ from danswer.db.models import Document
|
||||
from danswer.db.models import DocumentByConnectorCredentialPair
|
||||
from danswer.db.models import DocumentSet as DocumentSetDBModel
|
||||
from danswer.db.models import DocumentSet__ConnectorCredentialPair
|
||||
from danswer.db.models import DocumentSet__UserGroup
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import User__UserGroup
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.server.features.document_set.models import DocumentSetCreationRequest
|
||||
from danswer.server.features.document_set.models import DocumentSetUpdateRequest
|
||||
from danswer.utils.variable_functionality import fetch_versioned_implementation
|
||||
@ -341,9 +348,58 @@ def fetch_document_sets(
|
||||
]
|
||||
|
||||
|
||||
def fetch_all_document_sets(db_session: Session) -> Sequence[DocumentSetDBModel]:
|
||||
"""Used for Admin UI where they should have visibility into all document sets"""
|
||||
return db_session.scalars(select(DocumentSetDBModel)).all()
|
||||
def _add_user_filters(
|
||||
stmt: Select, user: User | None, get_editable: bool = True
|
||||
) -> Select:
|
||||
# If user is None, assume the user is an admin or auth is disabled
|
||||
if user is None or user.role == UserRole.ADMIN:
|
||||
return stmt
|
||||
|
||||
DocumentSet__UG = aliased(DocumentSet__UserGroup)
|
||||
User__UG = aliased(User__UserGroup)
|
||||
"""
|
||||
Here we select cc_pairs by relation:
|
||||
User -> User__UserGroup -> DocumentSet__UserGroup -> DocumentSet
|
||||
"""
|
||||
stmt = stmt.outerjoin(DocumentSet__UG).outerjoin(
|
||||
User__UserGroup,
|
||||
User__UserGroup.user_group_id == DocumentSet__UG.user_group_id,
|
||||
)
|
||||
"""
|
||||
Filter DocumentSets by:
|
||||
- if the user is in the user_group that owns the DocumentSet
|
||||
- if the user is not a global_curator, they must also have a curator relationship
|
||||
to the user_group
|
||||
- if editing is being done, we also filter out DocumentSets that are owned by groups
|
||||
that the user isn't a curator for
|
||||
- if we are not editing, we show all DocumentSets in the groups the user is a curator
|
||||
for (as well as public DocumentSets)
|
||||
"""
|
||||
where_clause = User__UserGroup.user_id == user.id
|
||||
if user.role == UserRole.CURATOR and get_editable:
|
||||
where_clause &= User__UserGroup.is_curator == True # noqa: E712
|
||||
if get_editable:
|
||||
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
|
||||
if user.role == UserRole.CURATOR:
|
||||
user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712
|
||||
where_clause &= (
|
||||
~exists()
|
||||
.where(DocumentSet__UG.document_set_id == DocumentSetDBModel.id)
|
||||
.where(~DocumentSet__UG.user_group_id.in_(user_groups))
|
||||
.correlate(DocumentSetDBModel)
|
||||
)
|
||||
else:
|
||||
where_clause |= DocumentSetDBModel.is_public == True # noqa: E712
|
||||
|
||||
return stmt.where(where_clause)
|
||||
|
||||
|
||||
def fetch_all_document_sets_for_user(
|
||||
db_session: Session, user: User | None = None, get_editable: bool = True
|
||||
) -> Sequence[DocumentSetDBModel]:
|
||||
stmt = select(DocumentSetDBModel).distinct()
|
||||
stmt = _add_user_filters(stmt, user, get_editable=get_editable)
|
||||
return db_session.scalars(stmt).all()
|
||||
|
||||
|
||||
def fetch_user_document_sets(
|
||||
|
@ -1,22 +1,36 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import asc
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import exists
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.configs.constants import SearchFeedbackType
|
||||
from danswer.db.chat import get_chat_message
|
||||
from danswer.db.models import ChatMessageFeedback
|
||||
from danswer.db.models import ConnectorCredentialPair
|
||||
from danswer.db.models import Document as DbDocument
|
||||
from danswer.db.models import DocumentByConnectorCredentialPair
|
||||
from danswer.db.models import DocumentRetrievalFeedback
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import User__UserGroup
|
||||
from danswer.db.models import UserGroup__ConnectorCredentialPair
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.document_index.interfaces import DocumentIndex
|
||||
from danswer.document_index.interfaces import UpdateRequest
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def fetch_db_doc_by_id(doc_id: str, db_session: Session) -> DbDocument:
|
||||
def _fetch_db_doc_by_id(doc_id: str, db_session: Session) -> DbDocument:
|
||||
stmt = select(DbDocument).where(DbDocument.id == doc_id)
|
||||
result = db_session.execute(stmt)
|
||||
doc = result.scalar_one_or_none()
|
||||
@ -27,15 +41,78 @@ def fetch_db_doc_by_id(doc_id: str, db_session: Session) -> DbDocument:
|
||||
return doc
|
||||
|
||||
|
||||
def _add_user_filters(
|
||||
stmt: Select, user: User | None, get_editable: bool = True
|
||||
) -> Select:
|
||||
# If user is None, assume the user is an admin or auth is disabled
|
||||
if user is None or user.role == UserRole.ADMIN:
|
||||
return stmt
|
||||
|
||||
DocByCC = aliased(DocumentByConnectorCredentialPair)
|
||||
CCPair = aliased(ConnectorCredentialPair)
|
||||
UG__CCpair = aliased(UserGroup__ConnectorCredentialPair)
|
||||
User__UG = aliased(User__UserGroup)
|
||||
|
||||
"""
|
||||
Here we select documents by relation:
|
||||
User -> User__UserGroup -> UserGroup__ConnectorCredentialPair ->
|
||||
ConnectorCredentialPair -> DocumentByConnectorCredentialPair -> Document
|
||||
"""
|
||||
stmt = (
|
||||
stmt.outerjoin(DocByCC, DocByCC.id == DbDocument.id)
|
||||
.outerjoin(
|
||||
CCPair,
|
||||
and_(
|
||||
CCPair.connector_id == DocByCC.connector_id,
|
||||
CCPair.credential_id == DocByCC.credential_id,
|
||||
),
|
||||
)
|
||||
.outerjoin(UG__CCpair, UG__CCpair.cc_pair_id == CCPair.id)
|
||||
.outerjoin(User__UG, User__UG.user_group_id == UG__CCpair.user_group_id)
|
||||
)
|
||||
|
||||
"""
|
||||
Filter Documents by:
|
||||
- if the user is in the user_group that owns the object
|
||||
- if the user is not a global_curator, they must also have a curator relationship
|
||||
to the user_group
|
||||
- if editing is being done, we also filter out objects that are owned by groups
|
||||
that the user isn't a curator for
|
||||
- if we are not editing, we show all objects in the groups the user is a curator
|
||||
for (as well as public objects as well)
|
||||
"""
|
||||
where_clause = User__UG.user_id == user.id
|
||||
if user.role == UserRole.CURATOR and get_editable:
|
||||
where_clause &= User__UG.is_curator == True # noqa: E712
|
||||
if get_editable:
|
||||
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
|
||||
where_clause &= (
|
||||
~exists()
|
||||
.where(UG__CCpair.cc_pair_id == CCPair.id)
|
||||
.where(~UG__CCpair.user_group_id.in_(user_groups))
|
||||
.correlate(CCPair)
|
||||
)
|
||||
else:
|
||||
where_clause |= CCPair.is_public == True # noqa: E712
|
||||
|
||||
return stmt.where(where_clause)
|
||||
|
||||
|
||||
def fetch_docs_ranked_by_boost(
|
||||
db_session: Session, ascending: bool = False, limit: int = 100
|
||||
db_session: Session,
|
||||
user: User | None = None,
|
||||
ascending: bool = False,
|
||||
limit: int = 100,
|
||||
) -> list[DbDocument]:
|
||||
order_func = asc if ascending else desc
|
||||
stmt = (
|
||||
select(DbDocument)
|
||||
.order_by(order_func(DbDocument.boost), order_func(DbDocument.semantic_id))
|
||||
.limit(limit)
|
||||
stmt = select(DbDocument)
|
||||
|
||||
stmt = _add_user_filters(stmt=stmt, user=user, get_editable=False)
|
||||
|
||||
stmt = stmt.order_by(
|
||||
order_func(DbDocument.boost), order_func(DbDocument.semantic_id)
|
||||
)
|
||||
stmt = stmt.limit(limit)
|
||||
result = db_session.execute(stmt)
|
||||
doc_list = result.scalars().all()
|
||||
|
||||
@ -43,12 +120,19 @@ def fetch_docs_ranked_by_boost(
|
||||
|
||||
|
||||
def update_document_boost(
|
||||
db_session: Session, document_id: str, boost: int, document_index: DocumentIndex
|
||||
db_session: Session,
|
||||
document_id: str,
|
||||
boost: int,
|
||||
document_index: DocumentIndex,
|
||||
user: User | None = None,
|
||||
) -> None:
|
||||
stmt = select(DbDocument).where(DbDocument.id == document_id)
|
||||
stmt = _add_user_filters(stmt, user, get_editable=True)
|
||||
result = db_session.execute(stmt).scalar_one_or_none()
|
||||
if result is None:
|
||||
raise ValueError(f"No document found with ID: '{document_id}'")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Document is not editable by this user"
|
||||
)
|
||||
|
||||
result.boost = boost
|
||||
|
||||
@ -63,12 +147,19 @@ def update_document_boost(
|
||||
|
||||
|
||||
def update_document_hidden(
|
||||
db_session: Session, document_id: str, hidden: bool, document_index: DocumentIndex
|
||||
db_session: Session,
|
||||
document_id: str,
|
||||
hidden: bool,
|
||||
document_index: DocumentIndex,
|
||||
user: User | None = None,
|
||||
) -> None:
|
||||
stmt = select(DbDocument).where(DbDocument.id == document_id)
|
||||
stmt = _add_user_filters(stmt, user, get_editable=True)
|
||||
result = db_session.execute(stmt).scalar_one_or_none()
|
||||
if result is None:
|
||||
raise ValueError(f"No document found with ID: '{document_id}'")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Document is not editable by this user"
|
||||
)
|
||||
|
||||
result.hidden = hidden
|
||||
|
||||
@ -92,7 +183,7 @@ def create_doc_retrieval_feedback(
|
||||
feedback: SearchFeedbackType | None = None,
|
||||
) -> None:
|
||||
"""Creates a new Document feedback row and updates the boost value in Postgres and Vespa"""
|
||||
db_doc = fetch_db_doc_by_id(document_id, db_session)
|
||||
db_doc = _fetch_db_doc_by_id(document_id, db_session)
|
||||
|
||||
retrieval_feedback = DocumentRetrievalFeedback(
|
||||
chat_message_id=message_id,
|
||||
|
@ -107,16 +107,14 @@ def fetch_existing_llm_providers(
|
||||
if not user:
|
||||
return list(db_session.scalars(select(LLMProviderModel)).all())
|
||||
stmt = select(LLMProviderModel).distinct()
|
||||
user_groups_subquery = (
|
||||
select(User__UserGroup.user_group_id)
|
||||
.where(User__UserGroup.user_id == user.id)
|
||||
.subquery()
|
||||
user_groups_select = select(User__UserGroup.user_group_id).where(
|
||||
User__UserGroup.user_id == user.id
|
||||
)
|
||||
access_conditions = or_(
|
||||
LLMProviderModel.is_public,
|
||||
LLMProviderModel.id.in_( # User is part of a group that has access
|
||||
select(LLMProvider__UserGroup.llm_provider_id).where(
|
||||
LLMProvider__UserGroup.user_group_id.in_(user_groups_subquery) # type: ignore
|
||||
LLMProvider__UserGroup.user_group_id.in_(user_groups_select) # type: ignore
|
||||
)
|
||||
),
|
||||
)
|
||||
|
@ -529,6 +529,8 @@ class Credential(Base):
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
curator_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
connectors: Mapped[list["ConnectorCredentialPair"]] = relationship(
|
||||
"ConnectorCredentialPair",
|
||||
back_populates="credential",
|
||||
@ -1458,6 +1460,8 @@ class SamlAccount(Base):
|
||||
class User__UserGroup(Base):
|
||||
__tablename__ = "user__user_group"
|
||||
|
||||
is_curator: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
user_group_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("user_group.id"), primary_key=True
|
||||
)
|
||||
@ -1522,6 +1526,17 @@ class DocumentSet__UserGroup(Base):
|
||||
)
|
||||
|
||||
|
||||
class Credential__UserGroup(Base):
|
||||
__tablename__ = "credential__user_group"
|
||||
|
||||
credential_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("credential.id"), primary_key=True
|
||||
)
|
||||
user_group_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("user_group.id"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class UserGroup(Base):
|
||||
__tablename__ = "user_group"
|
||||
|
||||
@ -1538,6 +1553,10 @@ class UserGroup(Base):
|
||||
"User",
|
||||
secondary=User__UserGroup.__table__,
|
||||
)
|
||||
user_group_relationships: Mapped[list[User__UserGroup]] = relationship(
|
||||
"User__UserGroup",
|
||||
viewonly=True,
|
||||
)
|
||||
cc_pairs: Mapped[list[ConnectorCredentialPair]] = relationship(
|
||||
"ConnectorCredentialPair",
|
||||
secondary=UserGroup__ConnectorCredentialPair.__table__,
|
||||
@ -1559,6 +1578,10 @@ class UserGroup(Base):
|
||||
secondary=DocumentSet__UserGroup.__table__,
|
||||
viewonly=True,
|
||||
)
|
||||
credentials: Mapped[list[Credential]] = relationship(
|
||||
"Credential",
|
||||
secondary=Credential__UserGroup.__table__,
|
||||
)
|
||||
|
||||
|
||||
"""Tables related to Token Rate Limiting
|
||||
|
@ -4,13 +4,15 @@ from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import exists
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import not_
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.schemas import UserRole
|
||||
@ -38,6 +40,89 @@ from danswer.utils.variable_functionality import fetch_versioned_implementation
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def _add_user_filters(
|
||||
stmt: Select, user: User | None, get_editable: bool = True
|
||||
) -> Select:
|
||||
# If user is None, assume the user is an admin or auth is disabled
|
||||
if user is None or user.role == UserRole.ADMIN:
|
||||
return stmt
|
||||
|
||||
Persona__UG = aliased(Persona__UserGroup)
|
||||
User__UG = aliased(User__UserGroup)
|
||||
"""
|
||||
Here we select cc_pairs by relation:
|
||||
User -> User__UserGroup -> Persona__UserGroup -> Persona
|
||||
"""
|
||||
stmt = (
|
||||
stmt.outerjoin(Persona__UG)
|
||||
.outerjoin(
|
||||
User__UserGroup,
|
||||
User__UserGroup.user_group_id == Persona__UG.user_group_id,
|
||||
)
|
||||
.outerjoin(
|
||||
Persona__User,
|
||||
Persona__User.persona_id == Persona.id,
|
||||
)
|
||||
)
|
||||
"""
|
||||
Filter Personas by:
|
||||
- if the user is in the user_group that owns the Persona
|
||||
- if the user is not a global_curator, they must also have a curator relationship
|
||||
to the user_group
|
||||
- if editing is being done, we also filter out Personas that are owned by groups
|
||||
that the user isn't a curator for
|
||||
- if we are not editing, we show all Personas in the groups the user is a curator
|
||||
for (as well as public Personas)
|
||||
- if we are not editing, we return all Personas directly connected to the user
|
||||
"""
|
||||
where_clause = User__UserGroup.user_id == user.id
|
||||
if user.role == UserRole.CURATOR and get_editable:
|
||||
where_clause &= User__UserGroup.is_curator == True # noqa: E712
|
||||
if get_editable:
|
||||
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
|
||||
if user.role == UserRole.CURATOR:
|
||||
user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712
|
||||
where_clause &= (
|
||||
~exists()
|
||||
.where(Persona__UG.persona_id == Persona.id)
|
||||
.where(~Persona__UG.user_group_id.in_(user_groups))
|
||||
.correlate(Persona)
|
||||
)
|
||||
else:
|
||||
where_clause |= Persona.is_public == True # noqa: E712
|
||||
where_clause &= Persona.is_visible == True # noqa: E712
|
||||
where_clause |= Persona__User.user_id == user.id
|
||||
where_clause |= Persona.user_id == user.id
|
||||
|
||||
return stmt.where(where_clause)
|
||||
|
||||
|
||||
def fetch_persona_by_id(
|
||||
db_session: Session, persona_id: int, user: User | None, get_editable: bool = True
|
||||
) -> Persona:
|
||||
stmt = select(Persona).where(Persona.id == persona_id).distinct()
|
||||
stmt = _add_user_filters(stmt=stmt, user=user, get_editable=get_editable)
|
||||
persona = db_session.scalars(stmt).one_or_none()
|
||||
if not persona:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Persona with ID {persona_id} does not exist or user is not authorized to access it",
|
||||
)
|
||||
return persona
|
||||
|
||||
|
||||
def _get_persona_by_name(
|
||||
persona_name: str, user: User | None, db_session: Session
|
||||
) -> Persona | None:
|
||||
"""Admins can see all, regular users can only fetch their own.
|
||||
If user is None, assume the user is an admin or auth is disabled."""
|
||||
stmt = select(Persona).where(Persona.name == persona_name)
|
||||
if user and user.role != UserRole.ADMIN:
|
||||
stmt = stmt.where(Persona.user_id == user.id)
|
||||
result = db_session.execute(stmt).scalar_one_or_none()
|
||||
return result
|
||||
|
||||
|
||||
def make_persona_private(
|
||||
persona_id: int,
|
||||
user_ids: list[UUID] | None,
|
||||
@ -105,13 +190,9 @@ def update_persona_shared_users(
|
||||
"""Simplified version of `create_update_persona` which only touches the
|
||||
accessibility rather than any of the logic (e.g. prompt, connected data sources,
|
||||
etc.)."""
|
||||
persona = fetch_persona_by_id(db_session=db_session, persona_id=persona_id)
|
||||
if not persona:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Persona with ID {persona_id} not found"
|
||||
)
|
||||
|
||||
check_user_can_edit_persona(user=user, persona=persona)
|
||||
persona = fetch_persona_by_id(
|
||||
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
|
||||
)
|
||||
|
||||
if persona.is_public:
|
||||
raise HTTPException(status_code=400, detail="Cannot share public persona")
|
||||
@ -129,10 +210,6 @@ def update_persona_shared_users(
|
||||
)
|
||||
|
||||
|
||||
def fetch_persona_by_id(db_session: Session, persona_id: int) -> Persona | None:
|
||||
return db_session.scalar(select(Persona).where(Persona.id == persona_id))
|
||||
|
||||
|
||||
def get_prompts(
|
||||
user_id: UUID | None,
|
||||
db_session: Session,
|
||||
@ -152,36 +229,17 @@ def get_prompts(
|
||||
|
||||
|
||||
def get_personas(
|
||||
# if user_id is `None` assume the user is an admin or auth is disabled
|
||||
user_id: UUID | None,
|
||||
# if user is `None` assume the user is an admin or auth is disabled
|
||||
user: User | None,
|
||||
db_session: Session,
|
||||
get_editable: bool = True,
|
||||
include_default: bool = True,
|
||||
include_slack_bot_personas: bool = False,
|
||||
include_deleted: bool = False,
|
||||
joinedload_all: bool = False,
|
||||
) -> Sequence[Persona]:
|
||||
stmt = select(Persona).distinct()
|
||||
if user_id is not None:
|
||||
# Subquery to find all groups the user belongs to
|
||||
user_groups_subquery = (
|
||||
select(User__UserGroup.user_group_id)
|
||||
.where(User__UserGroup.user_id == user_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Include personas where the user is directly related or part of a user group that has access
|
||||
access_conditions = or_(
|
||||
Persona.is_public == True, # noqa: E712
|
||||
Persona.id.in_( # User has access through list of users with access
|
||||
select(Persona__User.persona_id).where(Persona__User.user_id == user_id)
|
||||
),
|
||||
Persona.id.in_( # User is part of a group that has access
|
||||
select(Persona__UserGroup.persona_id).where(
|
||||
Persona__UserGroup.user_group_id.in_(user_groups_subquery) # type: ignore
|
||||
)
|
||||
),
|
||||
)
|
||||
stmt = stmt.where(access_conditions)
|
||||
stmt = _add_user_filters(stmt=stmt, user=user, get_editable=get_editable)
|
||||
|
||||
if not include_default:
|
||||
stmt = stmt.where(Persona.default_persona.is_(False))
|
||||
@ -245,7 +303,7 @@ def update_all_personas_display_priority(
|
||||
db_session: Session,
|
||||
) -> None:
|
||||
"""Updates the display priority of all lives Personas"""
|
||||
personas = get_personas(user_id=None, db_session=db_session)
|
||||
personas = get_personas(user=None, db_session=db_session)
|
||||
available_persona_ids = {persona.id for persona in personas}
|
||||
if available_persona_ids != set(display_priority_map.keys()):
|
||||
raise ValueError("Invalid persona IDs provided")
|
||||
@ -346,7 +404,7 @@ def upsert_persona(
|
||||
if persona_id is not None:
|
||||
persona = db_session.query(Persona).filter_by(id=persona_id).first()
|
||||
else:
|
||||
persona = get_persona_by_name(
|
||||
persona = _get_persona_by_name(
|
||||
persona_name=name, user=user, db_session=db_session
|
||||
)
|
||||
|
||||
@ -383,7 +441,10 @@ def upsert_persona(
|
||||
if not default_persona and persona.default_persona:
|
||||
raise ValueError("Cannot update default persona with non-default.")
|
||||
|
||||
check_user_can_edit_persona(user=user, persona=persona)
|
||||
# this checks if the user has permission to edit the persona
|
||||
persona = fetch_persona_by_id(
|
||||
db_session=db_session, persona_id=persona.id, user=user, get_editable=True
|
||||
)
|
||||
|
||||
persona.name = name
|
||||
persona.description = description
|
||||
@ -485,8 +546,11 @@ def update_persona_visibility(
|
||||
persona_id: int,
|
||||
is_visible: bool,
|
||||
db_session: Session,
|
||||
user: User | None = None,
|
||||
) -> None:
|
||||
persona = get_persona_by_id(persona_id=persona_id, user=None, db_session=db_session)
|
||||
persona = fetch_persona_by_id(
|
||||
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
|
||||
)
|
||||
persona.is_visible = is_visible
|
||||
db_session.commit()
|
||||
|
||||
@ -499,23 +563,6 @@ def validate_persona_tools(tools: list[Tool]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def check_user_can_edit_persona(user: User | None, persona: Persona) -> None:
|
||||
# if user is None, assume that no-auth is turned on
|
||||
if user is None:
|
||||
return
|
||||
|
||||
# admins can edit everything
|
||||
if user.role == UserRole.ADMIN:
|
||||
return
|
||||
|
||||
# otherwise, make sure user owns persona
|
||||
if persona.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"User not authorized to edit persona with ID {persona.id}",
|
||||
)
|
||||
|
||||
|
||||
def get_prompts_by_ids(prompt_ids: list[int], db_session: Session) -> Sequence[Prompt]:
|
||||
"""Unsafe, can fetch prompts from all users"""
|
||||
if not prompt_ids:
|
||||
@ -587,54 +634,53 @@ def get_persona_by_id(
|
||||
include_deleted: bool = False,
|
||||
is_for_edit: bool = True, # NOTE: assume true for safety
|
||||
) -> Persona:
|
||||
stmt = (
|
||||
persona_stmt = (
|
||||
select(Persona)
|
||||
.options(selectinload(Persona.users), selectinload(Persona.groups))
|
||||
.distinct()
|
||||
.outerjoin(Persona.groups)
|
||||
.outerjoin(Persona.users)
|
||||
.outerjoin(UserGroup.user_group_relationships)
|
||||
.where(Persona.id == persona_id)
|
||||
)
|
||||
|
||||
or_conditions = []
|
||||
|
||||
# if user is an admin, they should have access to all Personas
|
||||
# and will skip the following clause
|
||||
if user is not None and user.role != UserRole.ADMIN:
|
||||
# the user is not an admin
|
||||
isPersonaUnowned = Persona.user_id.is_(
|
||||
None
|
||||
) # allow access if persona user id is None
|
||||
isUserCreator = (
|
||||
Persona.user_id == user.id
|
||||
) # allow access if user created the persona
|
||||
or_conditions.extend([isPersonaUnowned, isUserCreator])
|
||||
|
||||
# if we aren't editing, also give access if:
|
||||
# 1. the user is authorized for this persona
|
||||
# 2. the user is in an authorized group for this persona
|
||||
# 3. if the persona is public
|
||||
if not is_for_edit:
|
||||
isSharedWithUser = Persona.users.any(
|
||||
id=user.id
|
||||
) # allow access if user is in allowed users
|
||||
isSharedWithGroup = Persona.groups.any(
|
||||
UserGroup.users.any(id=user.id)
|
||||
) # allow access if user is in any allowed group
|
||||
or_conditions.extend([isSharedWithUser, isSharedWithGroup])
|
||||
or_conditions.append(Persona.is_public.is_(True))
|
||||
|
||||
if or_conditions:
|
||||
stmt = stmt.where(or_(*or_conditions))
|
||||
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(Persona.deleted.is_(False))
|
||||
persona_stmt = persona_stmt.where(Persona.deleted.is_(False))
|
||||
|
||||
result = db_session.execute(stmt)
|
||||
if not user or user.role == UserRole.ADMIN:
|
||||
result = db_session.execute(persona_stmt)
|
||||
persona = result.scalar_one_or_none()
|
||||
if persona is None:
|
||||
raise ValueError(
|
||||
f"Persona with ID {persona_id} does not exist or does not belong to user"
|
||||
)
|
||||
return persona
|
||||
|
||||
# or check if user owns persona
|
||||
or_conditions = Persona.user_id == user.id
|
||||
# allow access if persona user id is None
|
||||
or_conditions |= Persona.user_id == None # noqa: E711
|
||||
if not is_for_edit:
|
||||
# if the user is in a group related to the persona
|
||||
or_conditions |= User__UserGroup.user_id == user.id
|
||||
# if the user is in the .users of the persona
|
||||
or_conditions |= User.id == user.id
|
||||
or_conditions |= Persona.is_public == True # noqa: E712
|
||||
elif user.role == UserRole.GLOBAL_CURATOR:
|
||||
# global curators can edit personas for the groups they are in
|
||||
or_conditions |= User__UserGroup.user_id == user.id
|
||||
elif user.role == UserRole.CURATOR:
|
||||
# curators can edit personas for the groups they are curators of
|
||||
or_conditions |= (User__UserGroup.user_id == user.id) & (
|
||||
User__UserGroup.is_curator == True # noqa: E712
|
||||
)
|
||||
|
||||
persona_stmt = persona_stmt.where(or_conditions)
|
||||
result = db_session.execute(persona_stmt)
|
||||
persona = result.scalar_one_or_none()
|
||||
|
||||
if persona is None:
|
||||
raise ValueError(
|
||||
f"Persona with ID {persona_id} does not exist or does not belong to user"
|
||||
)
|
||||
|
||||
return persona
|
||||
|
||||
|
||||
@ -665,18 +711,6 @@ def get_prompt_by_name(
|
||||
return result
|
||||
|
||||
|
||||
def get_persona_by_name(
|
||||
persona_name: str, user: User | None, db_session: Session
|
||||
) -> Persona | None:
|
||||
"""Admins can see all, regular users can only fetch their own.
|
||||
If user is None, assume the user is an admin or auth is disabled."""
|
||||
stmt = select(Persona).where(Persona.name == persona_name)
|
||||
if user and user.role != UserRole.ADMIN:
|
||||
stmt = stmt.where(Persona.user_id == user.id)
|
||||
result = db_session.execute(stmt).scalar_one_or_none()
|
||||
return result
|
||||
|
||||
|
||||
def delete_persona_by_name(
|
||||
persona_name: str, db_session: Session, is_default: bool = True
|
||||
) -> None:
|
||||
|
@ -1,21 +1,41 @@
|
||||
from collections.abc import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.schema import Column
|
||||
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import User__UserGroup
|
||||
from danswer.db.models import UserRole
|
||||
|
||||
|
||||
def list_users(db_session: Session, q: str = "") -> Sequence[User]:
|
||||
def list_users(
|
||||
db_session: Session, email_filter_string: str = "", user: User | None = None
|
||||
) -> Sequence[User]:
|
||||
"""List all users. No pagination as of now, as the # of users
|
||||
is assumed to be relatively small (<< 1 million)"""
|
||||
query = db_session.query(User)
|
||||
if q:
|
||||
query = query.filter(Column("email").ilike("%{}%".format(q)))
|
||||
return query.all()
|
||||
stmt = select(User)
|
||||
|
||||
if email_filter_string:
|
||||
stmt = stmt.where(User.email.ilike(f"%{email_filter_string}%")) # type: ignore
|
||||
|
||||
if user and user.role != UserRole.ADMIN:
|
||||
stmt = stmt.join(User__UserGroup)
|
||||
where_clause = User__UserGroup.user_id == user.id
|
||||
if user.role == UserRole.CURATOR:
|
||||
where_clause &= User__UserGroup.is_curator == True # noqa: E712
|
||||
stmt = stmt.where(where_clause)
|
||||
|
||||
return db_session.scalars(stmt).unique().all()
|
||||
|
||||
|
||||
def get_user_by_email(email: str, db_session: Session) -> User | None:
|
||||
user = db_session.query(User).filter(User.email == email).first() # type: ignore
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def fetch_user_by_id(db_session: Session, user_id: UUID) -> User | None:
|
||||
user = db_session.query(User).filter(User.id == user_id).first() # type: ignore
|
||||
|
||||
return user
|
||||
|
@ -5,6 +5,7 @@ from fastapi.dependencies.models import Dependant
|
||||
from starlette.routing import BaseRoute
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.configs.app_configs import APP_API_PREFIX
|
||||
from danswer.server.danswer_api.ingestion import api_key_dep
|
||||
@ -93,6 +94,7 @@ def check_router_auth(
|
||||
if (
|
||||
depends_fn == current_user
|
||||
or depends_fn == current_admin_user
|
||||
or depends_fn == current_curator_or_admin_user
|
||||
or depends_fn == api_key_dep
|
||||
):
|
||||
found_auth = True
|
||||
|
@ -5,7 +5,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.background.celery.celery_utils import get_deletion_attempt_snapshot
|
||||
from danswer.db.connector_credential_pair import add_credential_to_connector
|
||||
@ -21,10 +21,14 @@ from danswer.db.index_attempt import cancel_indexing_attempts_for_ccpair
|
||||
from danswer.db.index_attempt import cancel_indexing_attempts_past_model
|
||||
from danswer.db.index_attempt import get_index_attempts_for_connector
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.server.documents.models import CCPairFullInfo
|
||||
from danswer.server.documents.models import ConnectorCredentialPairIdentifier
|
||||
from danswer.server.documents.models import ConnectorCredentialPairMetadata
|
||||
from danswer.server.models import StatusResponse
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
router = APIRouter(prefix="/manage")
|
||||
|
||||
@ -32,18 +36,20 @@ router = APIRouter(prefix="/manage")
|
||||
@router.get("/admin/cc-pair/{cc_pair_id}")
|
||||
def get_cc_pair_full_info(
|
||||
cc_pair_id: int,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> CCPairFullInfo:
|
||||
cc_pair = get_connector_credential_pair_from_id(
|
||||
cc_pair_id=cc_pair_id,
|
||||
db_session=db_session,
|
||||
cc_pair_id, db_session, user, get_editable=False
|
||||
)
|
||||
if cc_pair is None:
|
||||
if not cc_pair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Connector with ID {cc_pair_id} not found. Has it been deleted?",
|
||||
status_code=404, detail="CC Pair not found for current user permissions"
|
||||
)
|
||||
editable_cc_pair = get_connector_credential_pair_from_id(
|
||||
cc_pair_id, db_session, user, get_editable=True
|
||||
)
|
||||
is_editable_for_current_user = editable_cc_pair is not None
|
||||
|
||||
cc_pair_identifier = ConnectorCredentialPairIdentifier(
|
||||
connector_id=cc_pair.connector_id,
|
||||
@ -74,6 +80,7 @@ def get_cc_pair_full_info(
|
||||
db_session=db_session,
|
||||
),
|
||||
num_docs_indexed=documents_indexed,
|
||||
is_editable_for_current_user=is_editable_for_current_user,
|
||||
)
|
||||
|
||||
|
||||
@ -85,9 +92,21 @@ class CCStatusUpdateRequest(BaseModel):
|
||||
def update_cc_pair_status(
|
||||
cc_pair_id: int,
|
||||
status_update_request: CCStatusUpdateRequest,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
cc_pair = get_connector_credential_pair_from_id(
|
||||
cc_pair_id=cc_pair_id,
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
get_editable=True,
|
||||
)
|
||||
if not cc_pair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Connection not found for current user's permissions",
|
||||
)
|
||||
|
||||
if status_update_request.status == ConnectorCredentialPairStatus.PAUSED:
|
||||
cancel_indexing_attempts_for_ccpair(cc_pair_id, db_session)
|
||||
|
||||
@ -105,12 +124,19 @@ def update_cc_pair_status(
|
||||
def update_cc_pair_name(
|
||||
cc_pair_id: int,
|
||||
new_name: str,
|
||||
user: User | None = Depends(current_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StatusResponse[int]:
|
||||
cc_pair = get_connector_credential_pair_from_id(cc_pair_id, db_session)
|
||||
cc_pair = get_connector_credential_pair_from_id(
|
||||
cc_pair_id=cc_pair_id,
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
get_editable=True,
|
||||
)
|
||||
if not cc_pair:
|
||||
raise HTTPException(status_code=404, detail="CC Pair not found")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="CC Pair not found for current user's permissions"
|
||||
)
|
||||
|
||||
try:
|
||||
cc_pair.name = new_name
|
||||
@ -128,18 +154,27 @@ def associate_credential_to_connector(
|
||||
connector_id: int,
|
||||
credential_id: int,
|
||||
metadata: ConnectorCredentialPairMetadata,
|
||||
user: User | None = Depends(current_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StatusResponse[int]:
|
||||
if user and user.role != UserRole.ADMIN and metadata.is_public:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Public connections cannot be created by non-admin users",
|
||||
)
|
||||
|
||||
try:
|
||||
return add_credential_to_connector(
|
||||
response = add_credential_to_connector(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
cc_pair_name=metadata.name,
|
||||
is_public=metadata.is_public,
|
||||
user=user,
|
||||
db_session=db_session,
|
||||
groups=metadata.groups,
|
||||
)
|
||||
|
||||
return response
|
||||
except IntegrityError:
|
||||
raise HTTPException(status_code=400, detail="Name must be unique")
|
||||
|
||||
|
@ -5,6 +5,7 @@ from typing import cast
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from fastapi import Request
|
||||
from fastapi import Response
|
||||
from fastapi import UploadFile
|
||||
@ -12,6 +13,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.background.celery.celery_utils import get_deletion_attempt_snapshot
|
||||
from danswer.configs.app_configs import ENABLED_CONNECTOR_TYPES
|
||||
@ -67,15 +69,16 @@ from danswer.db.index_attempt import get_index_attempts_for_cc_pair
|
||||
from danswer.db.index_attempt import get_latest_finished_index_attempt_for_cc_pair
|
||||
from danswer.db.index_attempt import get_latest_index_attempts
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.dynamic_configs.interface import ConfigNotFoundError
|
||||
from danswer.file_store.file_store import get_default_file_store
|
||||
from danswer.server.documents.models import AuthStatus
|
||||
from danswer.server.documents.models import AuthUrl
|
||||
from danswer.server.documents.models import ConnectorBase
|
||||
from danswer.server.documents.models import ConnectorCredentialBase
|
||||
from danswer.server.documents.models import ConnectorCredentialPairIdentifier
|
||||
from danswer.server.documents.models import ConnectorIndexingStatus
|
||||
from danswer.server.documents.models import ConnectorSnapshot
|
||||
from danswer.server.documents.models import ConnectorUpdateRequest
|
||||
from danswer.server.documents.models import CredentialBase
|
||||
from danswer.server.documents.models import CredentialSnapshot
|
||||
from danswer.server.documents.models import FileUploadResponse
|
||||
@ -88,6 +91,9 @@ from danswer.server.documents.models import IndexAttemptSnapshot
|
||||
from danswer.server.documents.models import ObjectCreationIdResponse
|
||||
from danswer.server.documents.models import RunConnectorRequest
|
||||
from danswer.server.models import StatusResponse
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
_GMAIL_CREDENTIAL_ID_COOKIE_NAME = "gmail_credential_id"
|
||||
_GOOGLE_DRIVE_CREDENTIAL_ID_COOKIE_NAME = "google_drive_credential_id"
|
||||
@ -101,7 +107,7 @@ router = APIRouter(prefix="/manage")
|
||||
|
||||
@router.get("/admin/connector/gmail/app-credential")
|
||||
def check_google_app_gmail_credentials_exist(
|
||||
_: User = Depends(current_admin_user),
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
return {"client_id": get_google_app_gmail_cred().web.client_id}
|
||||
@ -139,7 +145,7 @@ def delete_google_app_gmail_credentials(
|
||||
|
||||
@router.get("/admin/connector/google-drive/app-credential")
|
||||
def check_google_app_credentials_exist(
|
||||
_: User = Depends(current_admin_user),
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
return {"client_id": get_google_app_cred().web.client_id}
|
||||
@ -177,7 +183,7 @@ def delete_google_app_credentials(
|
||||
|
||||
@router.get("/admin/connector/gmail/service-account-key")
|
||||
def check_google_service_gmail_account_key_exist(
|
||||
_: User = Depends(current_admin_user),
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
return {"service_account_email": get_gmail_service_account_key().client_email}
|
||||
@ -217,7 +223,7 @@ def delete_google_service_gmail_account_key(
|
||||
|
||||
@router.get("/admin/connector/google-drive/service-account-key")
|
||||
def check_google_service_account_key_exist(
|
||||
_: User = Depends(current_admin_user),
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
return {"service_account_email": get_service_account_key().client_email}
|
||||
@ -258,7 +264,7 @@ def delete_google_service_account_key(
|
||||
@router.put("/admin/connector/google-drive/service-account-credential")
|
||||
def upsert_service_account_credential(
|
||||
service_account_credential_request: GoogleServiceAccountCredentialRequest,
|
||||
user: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ObjectCreationIdResponse:
|
||||
"""Special API which allows the creation of a credential for a service account.
|
||||
@ -284,7 +290,7 @@ def upsert_service_account_credential(
|
||||
@router.put("/admin/connector/gmail/service-account-credential")
|
||||
def upsert_gmail_service_account_credential(
|
||||
service_account_credential_request: GoogleServiceAccountCredentialRequest,
|
||||
user: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ObjectCreationIdResponse:
|
||||
"""Special API which allows the creation of a credential for a service account.
|
||||
@ -345,7 +351,7 @@ def admin_google_drive_auth(
|
||||
@router.post("/admin/connector/file/upload")
|
||||
def upload_files(
|
||||
files: list[UploadFile],
|
||||
_: User = Depends(current_admin_user),
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> FileUploadResponse:
|
||||
for file in files:
|
||||
@ -372,13 +378,21 @@ def upload_files(
|
||||
@router.get("/admin/connector/indexing-status")
|
||||
def get_connector_indexing_status(
|
||||
secondary_index: bool = False,
|
||||
_: User = Depends(current_admin_user),
|
||||
user: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
get_editable: bool = Query(
|
||||
False, description="If true, return editable document sets"
|
||||
),
|
||||
) -> list[ConnectorIndexingStatus]:
|
||||
indexing_statuses: list[ConnectorIndexingStatus] = []
|
||||
|
||||
# TODO: make this one query
|
||||
cc_pairs = get_connector_credential_pairs(db_session)
|
||||
cc_pairs = get_connector_credential_pairs(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
get_editable=get_editable,
|
||||
)
|
||||
|
||||
cc_pair_identifiers = [
|
||||
ConnectorCredentialPairIdentifier(
|
||||
connector_id=cc_pair.connector_id, credential_id=cc_pair.credential_id
|
||||
@ -488,28 +502,74 @@ def _validate_connector_allowed(source: DocumentSource) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _check_connector_permissions(
|
||||
connector_data: ConnectorUpdateRequest, user: User | None
|
||||
) -> ConnectorBase:
|
||||
"""
|
||||
This is not a proper permission check, but this should prevent curators creating bad situations
|
||||
until a long-term solution is implemented (Replacing CC pairs/Connectors with Connections)
|
||||
"""
|
||||
if user and user.role != UserRole.ADMIN:
|
||||
if connector_data.is_public:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Public connectors can only be created by admins",
|
||||
)
|
||||
if not connector_data.groups:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Connectors created by curators must have groups",
|
||||
)
|
||||
return ConnectorBase(
|
||||
name=connector_data.name,
|
||||
source=connector_data.source,
|
||||
input_type=connector_data.input_type,
|
||||
connector_specific_config=connector_data.connector_specific_config,
|
||||
refresh_freq=connector_data.refresh_freq,
|
||||
prune_freq=connector_data.prune_freq,
|
||||
indexing_start=connector_data.indexing_start,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/connector")
|
||||
def create_connector_from_model(
|
||||
connector_data: ConnectorBase,
|
||||
_: User = Depends(current_admin_user),
|
||||
connector_data: ConnectorUpdateRequest,
|
||||
user: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ObjectCreationIdResponse:
|
||||
try:
|
||||
_validate_connector_allowed(connector_data.source)
|
||||
return create_connector(connector_data, db_session)
|
||||
connector_base = _check_connector_permissions(connector_data, user)
|
||||
return create_connector(
|
||||
db_session=db_session,
|
||||
connector_data=connector_base,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/admin/connector-with-mock-credential")
|
||||
def create_connector_with_mock_credential(
|
||||
connector_data: ConnectorCredentialBase,
|
||||
user: User = Depends(current_admin_user),
|
||||
connector_data: ConnectorUpdateRequest,
|
||||
user: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StatusResponse:
|
||||
if user and user.role != UserRole.ADMIN:
|
||||
if connector_data.is_public:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="User does not have permission to create public credentials",
|
||||
)
|
||||
if not connector_data.groups:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Curators must specify 1+ groups",
|
||||
)
|
||||
try:
|
||||
_validate_connector_allowed(connector_data.source)
|
||||
connector_response = create_connector(connector_data, db_session)
|
||||
connector_response = create_connector(
|
||||
db_session=db_session, connector_data=connector_data
|
||||
)
|
||||
mock_credential = CredentialBase(
|
||||
credential_json={}, admin_public=True, source=connector_data.source
|
||||
)
|
||||
@ -517,12 +577,13 @@ def create_connector_with_mock_credential(
|
||||
mock_credential, user=user, db_session=db_session
|
||||
)
|
||||
response = add_credential_to_connector(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
connector_id=cast(int, connector_response.id), # will aways be an int
|
||||
credential_id=credential.id,
|
||||
is_public=connector_data.is_public,
|
||||
user=user,
|
||||
db_session=db_session,
|
||||
is_public=connector_data.is_public or False,
|
||||
cc_pair_name=connector_data.name,
|
||||
groups=connector_data.groups,
|
||||
)
|
||||
return response
|
||||
|
||||
@ -533,16 +594,17 @@ def create_connector_with_mock_credential(
|
||||
@router.patch("/admin/connector/{connector_id}")
|
||||
def update_connector_from_model(
|
||||
connector_id: int,
|
||||
connector_data: ConnectorBase,
|
||||
_: User = Depends(current_admin_user),
|
||||
connector_data: ConnectorUpdateRequest,
|
||||
user: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ConnectorSnapshot | StatusResponse[int]:
|
||||
try:
|
||||
_validate_connector_allowed(connector_data.source)
|
||||
connector_base = _check_connector_permissions(connector_data, user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
updated_connector = update_connector(connector_id, connector_data, db_session)
|
||||
updated_connector = update_connector(connector_id, connector_base, db_session)
|
||||
if updated_connector is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Connector {connector_id} does not exist"
|
||||
@ -573,7 +635,10 @@ def delete_connector_by_id(
|
||||
) -> StatusResponse[int]:
|
||||
try:
|
||||
with db_session.begin():
|
||||
return delete_connector(db_session=db_session, connector_id=connector_id)
|
||||
return delete_connector(
|
||||
db_session=db_session,
|
||||
connector_id=connector_id,
|
||||
)
|
||||
except AssertionError:
|
||||
raise HTTPException(status_code=400, detail="Connector is not deletable")
|
||||
|
||||
@ -581,7 +646,7 @@ def delete_connector_by_id(
|
||||
@router.post("/admin/connector/run-once")
|
||||
def connector_run_once(
|
||||
run_info: RunConnectorRequest,
|
||||
_: User = Depends(current_admin_user),
|
||||
_: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StatusResponse[list[int]]:
|
||||
connector_id = run_info.connector_id
|
||||
|
@ -1,13 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.schemas import UserRole
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.auth.users import validate_curator_request
|
||||
from danswer.db.credentials import alter_credential
|
||||
from danswer.db.credentials import create_credential
|
||||
from danswer.db.credentials import CREDENTIAL_PERMISSIONS_TO_IGNORE
|
||||
from danswer.db.credentials import delete_credential
|
||||
from danswer.db.credentials import fetch_credential_by_id
|
||||
from danswer.db.credentials import fetch_credentials
|
||||
@ -17,27 +20,39 @@ from danswer.db.credentials import update_credential
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.models import DocumentSource
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.server.documents.models import CredentialBase
|
||||
from danswer.server.documents.models import CredentialDataUpdateRequest
|
||||
from danswer.server.documents.models import CredentialSnapshot
|
||||
from danswer.server.documents.models import CredentialSwapRequest
|
||||
from danswer.server.documents.models import ObjectCreationIdResponse
|
||||
from danswer.server.models import StatusResponse
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
router = APIRouter(prefix="/manage")
|
||||
|
||||
|
||||
def _ignore_credential_permissions(source: DocumentSource) -> bool:
|
||||
return source in CREDENTIAL_PERMISSIONS_TO_IGNORE
|
||||
|
||||
|
||||
"""Admin-only endpoints"""
|
||||
|
||||
|
||||
@router.get("/admin/credential")
|
||||
def list_credentials_admin(
|
||||
user: User = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[CredentialSnapshot]:
|
||||
"""Lists all public credentials"""
|
||||
credentials = fetch_credentials(db_session=db_session, user=user)
|
||||
credentials = fetch_credentials(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
get_editable=False,
|
||||
)
|
||||
return [
|
||||
CredentialSnapshot.from_credential_db_model(credential)
|
||||
for credential in credentials
|
||||
@ -47,13 +62,18 @@ def list_credentials_admin(
|
||||
@router.get("/admin/similar-credentials/{source_type}")
|
||||
def get_cc_source_full_info(
|
||||
source_type: DocumentSource,
|
||||
user: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
get_editable: bool = Query(
|
||||
False, description="If true, return editable credentials"
|
||||
),
|
||||
) -> list[CredentialSnapshot]:
|
||||
credentials = fetch_credentials_by_source(
|
||||
db_session=db_session, user=user, document_source=source_type
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
document_source=source_type,
|
||||
get_editable=get_editable,
|
||||
)
|
||||
|
||||
return [
|
||||
CredentialSnapshot.from_credential_db_model(credential)
|
||||
for credential in credentials
|
||||
@ -87,13 +107,13 @@ def delete_credential_by_id_admin(
|
||||
|
||||
@router.put("/admin/credentials/swap")
|
||||
def swap_credentials_for_connector(
|
||||
credentail_swap_req: CredentialSwapRequest,
|
||||
credential_swap_req: CredentialSwapRequest,
|
||||
user: User | None = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StatusResponse:
|
||||
connector_credential_pair = swap_credentials_connector(
|
||||
new_credential_id=credentail_swap_req.new_credential_id,
|
||||
connector_id=credentail_swap_req.connector_id,
|
||||
new_credential_id=credential_swap_req.new_credential_id,
|
||||
connector_id=credential_swap_req.connector_id,
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
)
|
||||
@ -105,6 +125,29 @@ def swap_credentials_for_connector(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/credential")
|
||||
def create_credential_from_model(
|
||||
credential_info: CredentialBase,
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ObjectCreationIdResponse:
|
||||
if (
|
||||
user
|
||||
and user.role != UserRole.ADMIN
|
||||
and not _ignore_credential_permissions(credential_info.source)
|
||||
):
|
||||
validate_curator_request(
|
||||
groups=credential_info.groups,
|
||||
is_public=credential_info.curator_public,
|
||||
)
|
||||
|
||||
credential = create_credential(credential_info, user, db_session)
|
||||
return ObjectCreationIdResponse(
|
||||
id=credential.id,
|
||||
credential=CredentialSnapshot.from_credential_db_model(credential),
|
||||
)
|
||||
|
||||
|
||||
"""Endpoints for all"""
|
||||
|
||||
|
||||
@ -120,26 +163,6 @@ def list_credentials(
|
||||
]
|
||||
|
||||
|
||||
@router.post("/credential")
|
||||
def create_credential_from_model(
|
||||
credential_info: CredentialBase,
|
||||
user: User | None = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ObjectCreationIdResponse:
|
||||
if user and user.role != UserRole.ADMIN and credential_info.admin_public:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Non-admin cannot create admin credential",
|
||||
)
|
||||
|
||||
credential = create_credential(credential_info, user, db_session)
|
||||
|
||||
return ObjectCreationIdResponse(
|
||||
id=credential.id,
|
||||
credential=CredentialSnapshot.from_credential_db_model(credential),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/credential/{credential_id}")
|
||||
def get_credential_by_id(
|
||||
credential_id: int,
|
||||
@ -195,9 +218,11 @@ def update_credential_from_model(
|
||||
id=updated_credential.id,
|
||||
credential_json=updated_credential.credential_json,
|
||||
user_id=updated_credential.user_id,
|
||||
name=updated_credential.name,
|
||||
admin_public=updated_credential.admin_public,
|
||||
time_created=updated_credential.time_created,
|
||||
time_updated=updated_credential.time_updated,
|
||||
curator_public=updated_credential.curator_public,
|
||||
)
|
||||
|
||||
|
||||
|
@ -45,8 +45,9 @@ class ConnectorBase(BaseModel):
|
||||
indexing_start: datetime | None
|
||||
|
||||
|
||||
class ConnectorCredentialBase(ConnectorBase):
|
||||
is_public: bool
|
||||
class ConnectorUpdateRequest(ConnectorBase):
|
||||
is_public: bool | None = None
|
||||
groups: list[int] | None = None
|
||||
|
||||
|
||||
class ConnectorSnapshot(ConnectorBase):
|
||||
@ -91,6 +92,8 @@ class CredentialBase(BaseModel):
|
||||
admin_public: bool
|
||||
source: DocumentSource
|
||||
name: str | None = None
|
||||
curator_public: bool = False
|
||||
groups: list[int] = []
|
||||
|
||||
|
||||
class CredentialSnapshot(CredentialBase):
|
||||
@ -98,6 +101,11 @@ class CredentialSnapshot(CredentialBase):
|
||||
user_id: UUID | None
|
||||
time_created: datetime
|
||||
time_updated: datetime
|
||||
name: str | None
|
||||
source: DocumentSource
|
||||
credential_json: dict[str, Any]
|
||||
admin_public: bool
|
||||
curator_public: bool
|
||||
|
||||
@classmethod
|
||||
def from_credential_db_model(cls, credential: Credential) -> "CredentialSnapshot":
|
||||
@ -105,7 +113,7 @@ class CredentialSnapshot(CredentialBase):
|
||||
id=credential.id,
|
||||
credential_json=(
|
||||
mask_credential_dict(credential.credential_json)
|
||||
if MASK_CREDENTIAL_PREFIX
|
||||
if MASK_CREDENTIAL_PREFIX and credential.credential_json
|
||||
else credential.credential_json
|
||||
),
|
||||
user_id=credential.user_id,
|
||||
@ -114,6 +122,7 @@ class CredentialSnapshot(CredentialBase):
|
||||
time_updated=credential.time_updated,
|
||||
source=credential.source or DocumentSource.NOT_APPLICABLE,
|
||||
name=credential.name,
|
||||
curator_public=credential.curator_public,
|
||||
)
|
||||
|
||||
|
||||
@ -185,6 +194,8 @@ class CCPairFullInfo(BaseModel):
|
||||
credential: CredentialSnapshot
|
||||
index_attempts: list[IndexAttemptSnapshot]
|
||||
latest_deletion_attempt: DeletionAttemptSnapshot | None
|
||||
is_public: bool
|
||||
is_editable_for_current_user: bool
|
||||
|
||||
@classmethod
|
||||
def from_models(
|
||||
@ -193,6 +204,7 @@ class CCPairFullInfo(BaseModel):
|
||||
index_attempt_models: list[IndexAttempt],
|
||||
latest_deletion_attempt: DeletionAttemptSnapshot | None,
|
||||
num_docs_indexed: int, # not ideal, but this must be computed separately
|
||||
is_editable_for_current_user: bool,
|
||||
) -> "CCPairFullInfo":
|
||||
return cls(
|
||||
id=cc_pair_model.id,
|
||||
@ -210,6 +222,8 @@ class CCPairFullInfo(BaseModel):
|
||||
for index_attempt_model in index_attempt_models
|
||||
],
|
||||
latest_deletion_attempt=latest_deletion_attempt,
|
||||
is_public=cc_pair_model.is_public,
|
||||
is_editable_for_current_user=is_editable_for_current_user,
|
||||
)
|
||||
|
||||
|
||||
@ -241,6 +255,7 @@ class ConnectorCredentialPairIdentifier(BaseModel):
|
||||
class ConnectorCredentialPairMetadata(BaseModel):
|
||||
name: str | None
|
||||
is_public: bool
|
||||
groups: list[int] | None
|
||||
|
||||
|
||||
class ConnectorCredentialPairDescriptor(BaseModel):
|
||||
|
@ -1,18 +1,22 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.auth.users import validate_curator_request
|
||||
from danswer.db.document_set import check_document_sets_are_public
|
||||
from danswer.db.document_set import fetch_all_document_sets
|
||||
from danswer.db.document_set import fetch_all_document_sets_for_user
|
||||
from danswer.db.document_set import fetch_user_document_sets
|
||||
from danswer.db.document_set import insert_document_set
|
||||
from danswer.db.document_set import mark_document_set_as_to_be_deleted
|
||||
from danswer.db.document_set import update_document_set
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.server.documents.models import ConnectorCredentialPairDescriptor
|
||||
from danswer.server.documents.models import ConnectorSnapshot
|
||||
from danswer.server.documents.models import CredentialSnapshot
|
||||
@ -29,9 +33,14 @@ router = APIRouter(prefix="/manage")
|
||||
@router.post("/admin/document-set")
|
||||
def create_document_set(
|
||||
document_set_creation_request: DocumentSetCreationRequest,
|
||||
user: User = Depends(current_admin_user),
|
||||
user: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> int:
|
||||
if user and user.role != UserRole.ADMIN:
|
||||
validate_curator_request(
|
||||
groups=document_set_creation_request.groups,
|
||||
is_public=document_set_creation_request.is_public,
|
||||
)
|
||||
try:
|
||||
document_set_db_model, _ = insert_document_set(
|
||||
document_set_creation_request=document_set_creation_request,
|
||||
@ -74,12 +83,17 @@ def delete_document_set(
|
||||
|
||||
@router.get("/admin/document-set")
|
||||
def list_document_sets_admin(
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
get_editable: bool = Query(
|
||||
False, description="If true, return editable document sets"
|
||||
),
|
||||
) -> list[DocumentSet]:
|
||||
return [
|
||||
DocumentSet.from_model(ds)
|
||||
for ds in fetch_all_document_sets(db_session=db_session)
|
||||
for ds in fetch_all_document_sets_for_user(
|
||||
db_session=db_session, user=user, get_editable=get_editable
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
@ -3,11 +3,13 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import Query
|
||||
from fastapi import UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.configs.constants import FileOrigin
|
||||
from danswer.db.engine import get_session
|
||||
@ -45,13 +47,14 @@ class IsVisibleRequest(BaseModel):
|
||||
def patch_persona_visibility(
|
||||
persona_id: int,
|
||||
is_visible_request: IsVisibleRequest,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
update_persona_visibility(
|
||||
persona_id=persona_id,
|
||||
is_visible=is_visible_request.is_visible,
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
@ -69,15 +72,17 @@ def patch_persona_display_priority(
|
||||
|
||||
@admin_router.get("")
|
||||
def list_personas_admin(
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
include_deleted: bool = False,
|
||||
get_editable: bool = Query(False, description="If true, return editable personas"),
|
||||
) -> list[PersonaSnapshot]:
|
||||
return [
|
||||
PersonaSnapshot.from_model(persona)
|
||||
for persona in get_personas(
|
||||
db_session=db_session,
|
||||
user_id=None, # user_id = None -> give back all personas
|
||||
user=user,
|
||||
get_editable=get_editable,
|
||||
include_deleted=include_deleted,
|
||||
joinedload_all=True,
|
||||
)
|
||||
@ -187,13 +192,13 @@ def list_personas(
|
||||
db_session: Session = Depends(get_session),
|
||||
include_deleted: bool = False,
|
||||
) -> list[PersonaSnapshot]:
|
||||
user_id = user.id if user is not None else None
|
||||
return [
|
||||
PersonaSnapshot.from_model(persona)
|
||||
for persona in get_personas(
|
||||
user_id=user_id,
|
||||
user=user,
|
||||
include_deleted=include_deleted,
|
||||
db_session=db_session,
|
||||
get_editable=False,
|
||||
joinedload_all=True,
|
||||
)
|
||||
]
|
||||
|
@ -9,6 +9,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.configs.app_configs import GENERATIVE_MODEL_ACCESS_CHECK_FREQ
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.configs.constants import KV_GEN_AI_KEY_CHECK_TIME
|
||||
@ -35,6 +36,7 @@ from danswer.server.documents.models import ConnectorCredentialPairIdentifier
|
||||
from danswer.server.manage.models import BoostDoc
|
||||
from danswer.server.manage.models import BoostUpdateRequest
|
||||
from danswer.server.manage.models import HiddenUpdateRequest
|
||||
from danswer.server.models import StatusResponse
|
||||
from danswer.utils.logger import setup_logger
|
||||
|
||||
router = APIRouter(prefix="/manage")
|
||||
@ -47,11 +49,14 @@ logger = setup_logger()
|
||||
def get_most_boosted_docs(
|
||||
ascending: bool,
|
||||
limit: int,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[BoostDoc]:
|
||||
boost_docs = fetch_docs_ranked_by_boost(
|
||||
ascending=ascending, limit=limit, db_session=db_session
|
||||
ascending=ascending,
|
||||
limit=limit,
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
)
|
||||
return [
|
||||
BoostDoc(
|
||||
@ -69,45 +74,43 @@ def get_most_boosted_docs(
|
||||
@router.post("/admin/doc-boosts")
|
||||
def document_boost_update(
|
||||
boost_update: BoostUpdateRequest,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
) -> StatusResponse:
|
||||
curr_ind_name, sec_ind_name = get_both_index_names(db_session)
|
||||
document_index = get_default_document_index(
|
||||
primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name
|
||||
)
|
||||
|
||||
try:
|
||||
update_document_boost(
|
||||
db_session=db_session,
|
||||
document_id=boost_update.document_id,
|
||||
boost=boost_update.boost,
|
||||
document_index=document_index,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
update_document_boost(
|
||||
db_session=db_session,
|
||||
document_id=boost_update.document_id,
|
||||
boost=boost_update.boost,
|
||||
document_index=document_index,
|
||||
user=user,
|
||||
)
|
||||
return StatusResponse(success=True, message="Updated document boost")
|
||||
|
||||
|
||||
@router.post("/admin/doc-hidden")
|
||||
def document_hidden_update(
|
||||
hidden_update: HiddenUpdateRequest,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
) -> StatusResponse:
|
||||
curr_ind_name, sec_ind_name = get_both_index_names(db_session)
|
||||
document_index = get_default_document_index(
|
||||
primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name
|
||||
)
|
||||
|
||||
try:
|
||||
update_document_hidden(
|
||||
db_session=db_session,
|
||||
document_id=hidden_update.document_id,
|
||||
hidden=hidden_update.hidden,
|
||||
document_index=document_index,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
update_document_hidden(
|
||||
db_session=db_session,
|
||||
document_id=hidden_update.document_id,
|
||||
hidden=hidden_update.hidden,
|
||||
document_index=document_index,
|
||||
user=user,
|
||||
)
|
||||
return StatusResponse(success=True, message="Updated document boost")
|
||||
|
||||
|
||||
@router.get("/admin/genai-api-key/validate")
|
||||
@ -145,7 +148,7 @@ def validate_existing_genai_api_key(
|
||||
@router.post("/admin/deletion-attempt")
|
||||
def create_deletion_attempt_for_connector_id(
|
||||
connector_credential_pair_identifier: ConnectorCredentialPairIdentifier,
|
||||
_: User = Depends(current_admin_user),
|
||||
user: User = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
from danswer.background.celery.celery_app import (
|
||||
@ -159,6 +162,8 @@ def create_deletion_attempt_for_connector_id(
|
||||
db_session=db_session,
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
user=user,
|
||||
get_editable=True,
|
||||
)
|
||||
if cc_pair is None:
|
||||
raise HTTPException(
|
||||
@ -196,5 +201,5 @@ def create_deletion_attempt_for_connector_id(
|
||||
if cc_pair.connector.source == DocumentSource.FILE:
|
||||
connector = cc_pair.connector
|
||||
file_store = get_default_file_store(db_session)
|
||||
for file_name in connector.connector_specific_config["file_locations"]:
|
||||
for file_name in connector.connector_specific_config.get("file_locations", []):
|
||||
file_store.delete_file(file_name)
|
||||
|
@ -89,6 +89,11 @@ class UserByEmail(BaseModel):
|
||||
user_email: str
|
||||
|
||||
|
||||
class UserRoleUpdateRequest(BaseModel):
|
||||
user_email: str
|
||||
new_role: UserRole
|
||||
|
||||
|
||||
class UserRoleResponse(BaseModel):
|
||||
role: str
|
||||
|
||||
|
@ -22,6 +22,7 @@ from danswer.auth.noauth_user import set_no_auth_user_preferences
|
||||
from danswer.auth.schemas import UserRole
|
||||
from danswer.auth.schemas import UserStatus
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.auth.users import optional_user
|
||||
from danswer.configs.app_configs import AUTH_TYPE
|
||||
@ -38,11 +39,13 @@ from danswer.server.manage.models import AllUsersResponse
|
||||
from danswer.server.manage.models import UserByEmail
|
||||
from danswer.server.manage.models import UserInfo
|
||||
from danswer.server.manage.models import UserRoleResponse
|
||||
from danswer.server.manage.models import UserRoleUpdateRequest
|
||||
from danswer.server.models import FullUserSnapshot
|
||||
from danswer.server.models import InvitedUserSnapshot
|
||||
from danswer.server.models import MinimalUserSnapshot
|
||||
from danswer.utils.logger import setup_logger
|
||||
from ee.danswer.db.api_key import is_api_key_email_address
|
||||
from ee.danswer.db.user_group import remove_curator_status__no_commit
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@ -52,42 +55,38 @@ router = APIRouter()
|
||||
USERS_PAGE_SIZE = 10
|
||||
|
||||
|
||||
@router.patch("/manage/promote-user-to-admin")
|
||||
def promote_admin(
|
||||
user_email: UserByEmail,
|
||||
_: User = Depends(current_admin_user),
|
||||
@router.patch("/manage/set-user-role")
|
||||
def set_user_role(
|
||||
user_role_update_request: UserRoleUpdateRequest,
|
||||
current_user: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
user_to_promote = get_user_by_email(
|
||||
email=user_email.user_email, db_session=db_session
|
||||
user_to_update = get_user_by_email(
|
||||
email=user_role_update_request.user_email, db_session=db_session
|
||||
)
|
||||
if not user_to_promote:
|
||||
if not user_to_update:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user_to_promote.role = UserRole.ADMIN
|
||||
db_session.add(user_to_promote)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
@router.patch("/manage/demote-admin-to-basic")
|
||||
async def demote_admin(
|
||||
user_email: UserByEmail,
|
||||
user: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
user_to_demote = get_user_by_email(
|
||||
email=user_email.user_email, db_session=db_session
|
||||
)
|
||||
if not user_to_demote:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user_to_demote.id == user.id:
|
||||
if user_role_update_request.new_role == UserRole.CURATOR:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Cannot demote yourself from admin role!"
|
||||
status_code=400,
|
||||
detail="Curator role must be set via the User Group Menu",
|
||||
)
|
||||
|
||||
user_to_demote.role = UserRole.BASIC
|
||||
db_session.add(user_to_demote)
|
||||
if user_to_update.role == user_role_update_request.new_role:
|
||||
return
|
||||
|
||||
if current_user.id == user_to_update.id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="An admin cannot demote themselves from admin role!",
|
||||
)
|
||||
|
||||
if user_to_update.role == UserRole.CURATOR:
|
||||
remove_curator_status__no_commit(db_session, user_to_update)
|
||||
|
||||
user_to_update.role = user_role_update_request.new_role.value
|
||||
|
||||
db_session.commit()
|
||||
|
||||
|
||||
@ -96,7 +95,7 @@ def list_all_users(
|
||||
q: str | None = None,
|
||||
accepted_page: int | None = None,
|
||||
invited_page: int | None = None,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> AllUsersResponse:
|
||||
if not q:
|
||||
@ -104,7 +103,7 @@ def list_all_users(
|
||||
|
||||
users = [
|
||||
user
|
||||
for user in list_users(db_session, q=q)
|
||||
for user in list_users(db_session, email_filter_string=q, user=user)
|
||||
if not is_api_key_email_address(user.email)
|
||||
]
|
||||
accepted_emails = {user.email for user in users}
|
||||
|
@ -4,7 +4,7 @@ from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.configs.constants import MessageType
|
||||
@ -50,7 +50,7 @@ basic_router = APIRouter(prefix="/query")
|
||||
@admin_router.post("/search")
|
||||
def admin_search(
|
||||
question: AdminSearchRequest,
|
||||
user: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> AdminSearchResponse:
|
||||
query = question.query
|
||||
|
@ -1,16 +1,70 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import exists
|
||||
from sqlalchemy import Row
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.configs.constants import TokenRateLimitScope
|
||||
from danswer.db.models import TokenRateLimit
|
||||
from danswer.db.models import TokenRateLimit__UserGroup
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import User__UserGroup
|
||||
from danswer.db.models import UserGroup
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.server.token_rate_limits.models import TokenRateLimitArgs
|
||||
|
||||
|
||||
def _add_user_filters(
|
||||
stmt: Select, user: User | None, get_editable: bool = True
|
||||
) -> Select:
|
||||
# If user is None, assume the user is an admin or auth is disabled
|
||||
if user is None or user.role == UserRole.ADMIN:
|
||||
return stmt
|
||||
|
||||
TRLimit_UG = aliased(TokenRateLimit__UserGroup)
|
||||
User__UG = aliased(User__UserGroup)
|
||||
|
||||
"""
|
||||
Here we select token_rate_limits by relation:
|
||||
User -> User__UserGroup -> TokenRateLimit__UserGroup ->
|
||||
TokenRateLimit
|
||||
"""
|
||||
stmt = stmt.outerjoin(TRLimit_UG).outerjoin(
|
||||
User__UG,
|
||||
User__UG.user_group_id == TRLimit_UG.user_group_id,
|
||||
)
|
||||
|
||||
"""
|
||||
Filter token_rate_limits by:
|
||||
- if the user is in the user_group that owns the token_rate_limit
|
||||
- if the user is not a global_curator, they must also have a curator relationship
|
||||
to the user_group
|
||||
- if editing is being done, we also filter out token_rate_limits that are owned by groups
|
||||
that the user isn't a curator for
|
||||
- if we are not editing, we show all token_rate_limits in the groups the user curates
|
||||
"""
|
||||
where_clause = User__UG.user_id == user.id
|
||||
if user.role == UserRole.CURATOR and get_editable:
|
||||
where_clause &= User__UG.is_curator == True # noqa: E712
|
||||
if get_editable:
|
||||
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
|
||||
if user.role == UserRole.CURATOR:
|
||||
user_groups = user_groups.where(
|
||||
User__UserGroup.is_curator == True # noqa: E712
|
||||
)
|
||||
where_clause &= (
|
||||
~exists()
|
||||
.where(TRLimit_UG.rate_limit_id == TokenRateLimit.id)
|
||||
.where(~TRLimit_UG.user_group_id.in_(user_groups))
|
||||
.correlate(TokenRateLimit)
|
||||
)
|
||||
|
||||
return stmt.where(where_clause)
|
||||
|
||||
|
||||
def fetch_all_user_token_rate_limits(
|
||||
db_session: Session,
|
||||
enabled_only: bool = False,
|
||||
@ -48,29 +102,25 @@ def fetch_all_global_token_rate_limits(
|
||||
return token_rate_limits
|
||||
|
||||
|
||||
def fetch_all_user_group_token_rate_limits(
|
||||
db_session: Session, group_id: int, enabled_only: bool = False, ordered: bool = True
|
||||
def fetch_user_group_token_rate_limits(
|
||||
db_session: Session,
|
||||
group_id: int,
|
||||
user: User | None = None,
|
||||
enabled_only: bool = False,
|
||||
ordered: bool = True,
|
||||
get_editable: bool = True,
|
||||
) -> Sequence[TokenRateLimit]:
|
||||
query = (
|
||||
select(TokenRateLimit)
|
||||
.join(
|
||||
TokenRateLimit__UserGroup,
|
||||
TokenRateLimit.id == TokenRateLimit__UserGroup.rate_limit_id,
|
||||
)
|
||||
.where(
|
||||
TokenRateLimit__UserGroup.user_group_id == group_id,
|
||||
TokenRateLimit.scope == TokenRateLimitScope.USER_GROUP,
|
||||
)
|
||||
)
|
||||
stmt = select(TokenRateLimit)
|
||||
stmt = stmt.where(User__UserGroup.user_group_id == group_id)
|
||||
stmt = _add_user_filters(stmt, user, get_editable)
|
||||
|
||||
if enabled_only:
|
||||
query = query.where(TokenRateLimit.enabled.is_(True))
|
||||
stmt = stmt.where(TokenRateLimit.enabled.is_(True))
|
||||
|
||||
if ordered:
|
||||
query = query.order_by(TokenRateLimit.created_at.desc())
|
||||
stmt = stmt.order_by(TokenRateLimit.created_at.desc())
|
||||
|
||||
token_rate_limits = db_session.scalars(query).all()
|
||||
return token_rate_limits
|
||||
return db_session.scalars(stmt).all()
|
||||
|
||||
|
||||
def fetch_all_user_group_token_rate_limits_by_group(
|
||||
|
@ -5,11 +5,13 @@ from uuid import UUID
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.db.connector_credential_pair import get_connector_credential_pair_from_id
|
||||
from danswer.db.enums import ConnectorCredentialPairStatus
|
||||
from danswer.db.models import ConnectorCredentialPair
|
||||
from danswer.db.models import Credential__UserGroup
|
||||
from danswer.db.models import Document
|
||||
from danswer.db.models import DocumentByConnectorCredentialPair
|
||||
from danswer.db.models import LLMProvider__UserGroup
|
||||
@ -18,9 +20,15 @@ from danswer.db.models import User
|
||||
from danswer.db.models import User__UserGroup
|
||||
from danswer.db.models import UserGroup
|
||||
from danswer.db.models import UserGroup__ConnectorCredentialPair
|
||||
from danswer.db.models import UserRole
|
||||
from danswer.db.users import fetch_user_by_id
|
||||
from danswer.utils.logger import setup_logger
|
||||
from ee.danswer.server.user_group.models import SetCuratorRequest
|
||||
from ee.danswer.server.user_group.models import UserGroupCreate
|
||||
from ee.danswer.server.user_group.models import UserGroupUpdate
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def fetch_user_group(db_session: Session, user_group_id: int) -> UserGroup | None:
|
||||
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
|
||||
@ -37,7 +45,7 @@ def fetch_user_groups(
|
||||
|
||||
|
||||
def fetch_user_groups_for_user(
|
||||
db_session: Session, user_id: UUID
|
||||
db_session: Session, user_id: UUID, only_curator_groups: bool = False
|
||||
) -> Sequence[UserGroup]:
|
||||
stmt = (
|
||||
select(UserGroup)
|
||||
@ -45,6 +53,8 @@ def fetch_user_groups_for_user(
|
||||
.join(User, User.id == User__UserGroup.user_id) # type: ignore
|
||||
.where(User.id == user_id) # type: ignore
|
||||
)
|
||||
if only_curator_groups:
|
||||
stmt = stmt.where(User__UserGroup.is_curator == True) # noqa: E712
|
||||
return db_session.scalars(stmt).all()
|
||||
|
||||
|
||||
@ -179,16 +189,32 @@ def insert_user_group(db_session: Session, user_group: UserGroupCreate) -> UserG
|
||||
|
||||
|
||||
def _cleanup_user__user_group_relationships__no_commit(
|
||||
db_session: Session, user_group_id: int
|
||||
db_session: Session,
|
||||
user_group_id: int,
|
||||
user_ids: list[UUID] | None = None,
|
||||
) -> None:
|
||||
"""NOTE: does not commit the transaction."""
|
||||
where_clause = User__UserGroup.user_group_id == user_group_id
|
||||
if user_ids:
|
||||
where_clause &= User__UserGroup.user_id.in_(user_ids)
|
||||
|
||||
user__user_group_relationships = db_session.scalars(
|
||||
select(User__UserGroup).where(User__UserGroup.user_group_id == user_group_id)
|
||||
select(User__UserGroup).where(where_clause)
|
||||
).all()
|
||||
for user__user_group_relationship in user__user_group_relationships:
|
||||
db_session.delete(user__user_group_relationship)
|
||||
|
||||
|
||||
def _cleanup_credential__user_group_relationships__no_commit(
|
||||
db_session: Session,
|
||||
user_group_id: int,
|
||||
) -> None:
|
||||
"""NOTE: does not commit the transaction."""
|
||||
db_session.query(Credential__UserGroup).filter(
|
||||
Credential__UserGroup.user_group_id == user_group_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
|
||||
def _cleanup_llm_provider__user_group_relationships__no_commit(
|
||||
db_session: Session, user_group_id: int
|
||||
) -> None:
|
||||
@ -211,8 +237,84 @@ def _mark_user_group__cc_pair_relationships_outdated__no_commit(
|
||||
user_group__cc_pair_relationship.is_current = False
|
||||
|
||||
|
||||
def _validate_curator_status__no_commit(
|
||||
db_session: Session,
|
||||
users: list[User],
|
||||
) -> None:
|
||||
for user in users:
|
||||
# Check if the user is a curator in any of their groups
|
||||
curator_relationships = (
|
||||
db_session.query(User__UserGroup)
|
||||
.filter(
|
||||
User__UserGroup.user_id == user.id,
|
||||
User__UserGroup.is_curator == True, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if curator_relationships:
|
||||
user.role = UserRole.CURATOR
|
||||
elif user.role == UserRole.CURATOR:
|
||||
user.role = UserRole.BASIC
|
||||
db_session.add(user)
|
||||
|
||||
|
||||
def remove_curator_status__no_commit(db_session: Session, user: User) -> None:
|
||||
stmt = (
|
||||
update(User__UserGroup)
|
||||
.where(User__UserGroup.user_id == user.id)
|
||||
.values(is_curator=False)
|
||||
)
|
||||
db_session.execute(stmt)
|
||||
_validate_curator_status__no_commit(db_session, [user])
|
||||
|
||||
|
||||
def update_user_curator_relationship(
|
||||
db_session: Session,
|
||||
user_group_id: int,
|
||||
set_curator_request: SetCuratorRequest,
|
||||
) -> None:
|
||||
user = fetch_user_by_id(db_session, set_curator_request.user_id)
|
||||
if not user:
|
||||
raise ValueError(f"User with id '{set_curator_request.user_id}' not found")
|
||||
requested_user_groups = fetch_user_groups_for_user(
|
||||
db_session=db_session,
|
||||
user_id=set_curator_request.user_id,
|
||||
only_curator_groups=False,
|
||||
)
|
||||
|
||||
group_ids = [group.id for group in requested_user_groups]
|
||||
if user_group_id not in group_ids:
|
||||
raise ValueError(f"user is not in group '{user_group_id}'")
|
||||
|
||||
relationship_to_update = (
|
||||
db_session.query(User__UserGroup)
|
||||
.filter(
|
||||
User__UserGroup.user_group_id == user_group_id,
|
||||
User__UserGroup.user_id == set_curator_request.user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if relationship_to_update:
|
||||
relationship_to_update.is_curator = set_curator_request.is_curator
|
||||
else:
|
||||
relationship_to_update = User__UserGroup(
|
||||
user_group_id=user_group_id,
|
||||
user_id=set_curator_request.user_id,
|
||||
is_curator=True,
|
||||
)
|
||||
db_session.add(relationship_to_update)
|
||||
|
||||
_validate_curator_status__no_commit(db_session, [user])
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def update_user_group(
|
||||
db_session: Session, user_group_id: int, user_group: UserGroupUpdate
|
||||
db_session: Session,
|
||||
user: User | None,
|
||||
user_group_id: int,
|
||||
user_group_update: UserGroupUpdate,
|
||||
) -> UserGroup:
|
||||
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
|
||||
db_user_group = db_session.scalar(stmt)
|
||||
@ -221,23 +323,33 @@ def update_user_group(
|
||||
|
||||
_check_user_group_is_modifiable(db_user_group)
|
||||
|
||||
existing_cc_pairs = db_user_group.cc_pairs
|
||||
cc_pairs_updated = set([cc_pair.id for cc_pair in existing_cc_pairs]) != set(
|
||||
user_group.cc_pair_ids
|
||||
)
|
||||
users_updated = set([user.id for user in db_user_group.users]) != set(
|
||||
user_group.user_ids
|
||||
)
|
||||
current_user_ids = set([user.id for user in db_user_group.users])
|
||||
updated_user_ids = set(user_group_update.user_ids)
|
||||
added_user_ids = list(updated_user_ids - current_user_ids)
|
||||
removed_user_ids = list(current_user_ids - updated_user_ids)
|
||||
|
||||
if users_updated:
|
||||
if (removed_user_ids or added_user_ids) and (
|
||||
not user or user.role != UserRole.ADMIN
|
||||
):
|
||||
raise ValueError("Only admins can add or remove users from user groups")
|
||||
|
||||
if removed_user_ids:
|
||||
_cleanup_user__user_group_relationships__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
db_session=db_session,
|
||||
user_group_id=user_group_id,
|
||||
user_ids=removed_user_ids,
|
||||
)
|
||||
|
||||
if added_user_ids:
|
||||
_add_user__user_group_relationships__no_commit(
|
||||
db_session=db_session,
|
||||
user_group_id=user_group_id,
|
||||
user_ids=user_group.user_ids,
|
||||
user_ids=added_user_ids,
|
||||
)
|
||||
|
||||
cc_pairs_updated = set([cc_pair.id for cc_pair in db_user_group.cc_pairs]) != set(
|
||||
user_group_update.cc_pair_ids
|
||||
)
|
||||
if cc_pairs_updated:
|
||||
_mark_user_group__cc_pair_relationships_outdated__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
@ -245,13 +357,17 @@ def update_user_group(
|
||||
_add_user_group__cc_pair_relationships__no_commit(
|
||||
db_session=db_session,
|
||||
user_group_id=db_user_group.id,
|
||||
cc_pair_ids=user_group.cc_pair_ids,
|
||||
cc_pair_ids=user_group_update.cc_pair_ids,
|
||||
)
|
||||
|
||||
# only needs to sync with Vespa if the cc_pairs have been updated
|
||||
if cc_pairs_updated:
|
||||
db_user_group.is_up_to_date = False
|
||||
|
||||
removed_users = db_session.scalars(
|
||||
select(User).where(User.id.in_(removed_user_ids)) # type: ignore
|
||||
).unique()
|
||||
_validate_curator_status__no_commit(db_session, list(removed_users))
|
||||
db_session.commit()
|
||||
return db_user_group
|
||||
|
||||
@ -279,6 +395,9 @@ def prepare_user_group_for_deletion(db_session: Session, user_group_id: int) ->
|
||||
|
||||
_check_user_group_is_modifiable(db_user_group)
|
||||
|
||||
_cleanup_credential__user_group_relationships__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
)
|
||||
_cleanup_user__user_group_relationships__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
)
|
||||
|
@ -5,14 +5,15 @@ from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.models import User
|
||||
from danswer.server.query_and_chat.token_limit import any_rate_limit_exists
|
||||
from danswer.server.token_rate_limits.models import TokenRateLimitArgs
|
||||
from danswer.server.token_rate_limits.models import TokenRateLimitDisplay
|
||||
from ee.danswer.db.token_limit import fetch_all_user_group_token_rate_limits
|
||||
from ee.danswer.db.token_limit import fetch_all_user_group_token_rate_limits_by_group
|
||||
from ee.danswer.db.token_limit import fetch_all_user_token_rate_limits
|
||||
from ee.danswer.db.token_limit import fetch_user_group_token_rate_limits
|
||||
from ee.danswer.db.token_limit import insert_user_group_token_rate_limit
|
||||
from ee.danswer.db.token_limit import insert_user_token_rate_limit
|
||||
|
||||
@ -45,13 +46,13 @@ def get_all_group_token_limit_settings(
|
||||
@router.get("/user-group/{group_id}")
|
||||
def get_group_token_limit_settings(
|
||||
group_id: int,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[TokenRateLimitDisplay]:
|
||||
return [
|
||||
TokenRateLimitDisplay.from_db(token_rate_limit)
|
||||
for token_rate_limit in fetch_all_user_group_token_rate_limits(
|
||||
db_session, group_id
|
||||
for token_rate_limit in fetch_user_group_token_rate_limits(
|
||||
db_session, group_id, user
|
||||
)
|
||||
]
|
||||
|
||||
|
@ -5,12 +5,17 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.auth.users import current_curator_or_admin_user
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.models import User
|
||||
from danswer.db.models import UserRole
|
||||
from ee.danswer.db.user_group import fetch_user_groups
|
||||
from ee.danswer.db.user_group import fetch_user_groups_for_user
|
||||
from ee.danswer.db.user_group import insert_user_group
|
||||
from ee.danswer.db.user_group import prepare_user_group_for_deletion
|
||||
from ee.danswer.db.user_group import update_user_curator_relationship
|
||||
from ee.danswer.db.user_group import update_user_group
|
||||
from ee.danswer.server.user_group.models import SetCuratorRequest
|
||||
from ee.danswer.server.user_group.models import UserGroup
|
||||
from ee.danswer.server.user_group.models import UserGroupCreate
|
||||
from ee.danswer.server.user_group.models import UserGroupUpdate
|
||||
@ -20,10 +25,17 @@ router = APIRouter(prefix="/manage")
|
||||
|
||||
@router.get("/admin/user-group")
|
||||
def list_user_groups(
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[UserGroup]:
|
||||
user_groups = fetch_user_groups(db_session, only_current=False)
|
||||
if user is None or user.role == UserRole.ADMIN:
|
||||
user_groups = fetch_user_groups(db_session, only_current=False)
|
||||
else:
|
||||
user_groups = fetch_user_groups_for_user(
|
||||
db_session=db_session,
|
||||
user_id=user.id,
|
||||
only_curator_groups=user.role == UserRole.CURATOR,
|
||||
)
|
||||
return [UserGroup.from_model(user_group) for user_group in user_groups]
|
||||
|
||||
|
||||
@ -47,13 +59,35 @@ def create_user_group(
|
||||
@router.patch("/admin/user-group/{user_group_id}")
|
||||
def patch_user_group(
|
||||
user_group_id: int,
|
||||
user_group: UserGroupUpdate,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
user_group_update: UserGroupUpdate,
|
||||
user: User | None = Depends(current_curator_or_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserGroup:
|
||||
try:
|
||||
return UserGroup.from_model(
|
||||
update_user_group(db_session, user_group_id, user_group)
|
||||
update_user_group(
|
||||
db_session=db_session,
|
||||
user=user,
|
||||
user_group_id=user_group_id,
|
||||
user_group_update=user_group_update,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/admin/user-group/{user_group_id}/set-curator")
|
||||
def set_user_curator(
|
||||
user_group_id: int,
|
||||
set_curator_request: SetCuratorRequest,
|
||||
_: User | None = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
try:
|
||||
update_user_curator_relationship(
|
||||
db_session=db_session,
|
||||
user_group_id=user_group_id,
|
||||
set_curator_request=set_curator_request,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
@ -16,6 +16,7 @@ class UserGroup(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
users: list[UserInfo]
|
||||
curator_ids: list[UUID]
|
||||
cc_pairs: list[ConnectorCredentialPairDescriptor]
|
||||
document_sets: list[DocumentSet]
|
||||
personas: list[PersonaSnapshot]
|
||||
@ -42,6 +43,11 @@ class UserGroup(BaseModel):
|
||||
)
|
||||
for user in user_group_model.users
|
||||
],
|
||||
curator_ids=[
|
||||
user.user_id
|
||||
for user in user_group_model.user_group_relationships
|
||||
if user.is_curator and user.user_id is not None
|
||||
],
|
||||
cc_pairs=[
|
||||
ConnectorCredentialPairDescriptor(
|
||||
id=cc_pair_relationship.cc_pair.id,
|
||||
@ -78,3 +84,8 @@ class UserGroupCreate(BaseModel):
|
||||
class UserGroupUpdate(BaseModel):
|
||||
user_ids: list[UUID]
|
||||
cc_pair_ids: list[int]
|
||||
|
||||
|
||||
class SetCuratorRequest(BaseModel):
|
||||
user_id: UUID
|
||||
is_curator: bool
|
||||
|
@ -4,6 +4,7 @@ import { generateRandomIconShape, createSVG } from "@/lib/assistantIconUtils";
|
||||
|
||||
import { CCPairBasicInfo, DocumentSet, User } from "@/lib/types";
|
||||
import { Button, Divider, Italic, Text } from "@tremor/react";
|
||||
import { IsPublicGroupSelector } from "@/components/IsPublicGroupSelector";
|
||||
import {
|
||||
ArrayHelpers,
|
||||
ErrorMessage,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
FieldArray,
|
||||
Form,
|
||||
Formik,
|
||||
FormikProps,
|
||||
} from "formik";
|
||||
|
||||
import {
|
||||
@ -21,10 +23,8 @@ import {
|
||||
} from "@/components/admin/connectors/Field";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { getDisplayNameForModel } from "@/lib/hooks";
|
||||
import { Bubble } from "@/components/Bubble";
|
||||
import { DocumentSetSelectable } from "@/components/documentSet/DocumentSetSelectable";
|
||||
import { Option } from "@/components/Dropdown";
|
||||
import { GroupsIcon } from "@/components/icons/icons";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { addAssistantToList } from "@/lib/assistants/updateAssistantPreferences";
|
||||
import { useUserGroups } from "@/lib/hooks";
|
||||
@ -232,6 +232,9 @@ export function AssistantEditor({
|
||||
const [existingPersonaImageId, setExistingPersonaImageId] = useState<
|
||||
string | null
|
||||
>(existingPersona?.uploaded_image_id || null);
|
||||
|
||||
const [isRequestSuccessful, setIsRequestSuccessful] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{popup}
|
||||
@ -414,10 +417,16 @@ export function AssistantEditor({
|
||||
? `/admin/assistants?u=${Date.now()}`
|
||||
: `/chat?assistantId=${assistantId}`
|
||||
);
|
||||
setIsRequestSuccessful(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values, setFieldValue }) => {
|
||||
{({
|
||||
isSubmitting,
|
||||
values,
|
||||
setFieldValue,
|
||||
...formikProps
|
||||
}: FormikProps<any>) => {
|
||||
function toggleToolInValues(toolId: number) {
|
||||
const updatedEnabledToolsMap = {
|
||||
...values.enabled_tools_map,
|
||||
@ -891,24 +900,28 @@ export function AssistantEditor({
|
||||
<div>
|
||||
{values.starter_messages &&
|
||||
values.starter_messages.length > 0 &&
|
||||
values.starter_messages.map((_, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={index === 0 ? "mt-2" : "mt-6"}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="w-full mr-6 border border-border p-3 rounded">
|
||||
<div>
|
||||
<Label small>Name</Label>
|
||||
<SubLabel>
|
||||
Shows up as the "title" for
|
||||
this Starter Message. For example,
|
||||
"Write an email".
|
||||
</SubLabel>
|
||||
<Field
|
||||
name={`starter_messages[${index}].name`}
|
||||
className={`
|
||||
values.starter_messages.map(
|
||||
(
|
||||
starterMessage: StarterMessage,
|
||||
index: number
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={index === 0 ? "mt-2" : "mt-6"}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="w-full mr-6 border border-border p-3 rounded">
|
||||
<div>
|
||||
<Label small>Name</Label>
|
||||
<SubLabel>
|
||||
Shows up as the "title"
|
||||
for this Starter Message. For
|
||||
example, "Write an email".
|
||||
</SubLabel>
|
||||
<Field
|
||||
name={`starter_messages[${index}].name`}
|
||||
className={`
|
||||
border
|
||||
border-border
|
||||
bg-background
|
||||
@ -918,27 +931,27 @@ export function AssistantEditor({
|
||||
px-3
|
||||
mr-4
|
||||
`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ErrorMessage
|
||||
name={`starter_messages[${index}].name`}
|
||||
component="div"
|
||||
className="text-error text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ErrorMessage
|
||||
name={`starter_messages[${index}].name`}
|
||||
component="div"
|
||||
className="text-error text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label small>Description</Label>
|
||||
<SubLabel>
|
||||
A description which tells the user
|
||||
what they might want to use this
|
||||
Starter Message for. For example
|
||||
"to a client about a new
|
||||
feature"
|
||||
</SubLabel>
|
||||
<Field
|
||||
name={`starter_messages.${index}.description`}
|
||||
className={`
|
||||
<div className="mt-3">
|
||||
<Label small>Description</Label>
|
||||
<SubLabel>
|
||||
A description which tells the user
|
||||
what they might want to use this
|
||||
Starter Message for. For example
|
||||
"to a client about a new
|
||||
feature"
|
||||
</SubLabel>
|
||||
<Field
|
||||
name={`starter_messages.${index}.description`}
|
||||
className={`
|
||||
border
|
||||
border-border
|
||||
bg-background
|
||||
@ -948,28 +961,28 @@ export function AssistantEditor({
|
||||
px-3
|
||||
mr-4
|
||||
`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ErrorMessage
|
||||
name={`starter_messages[${index}].description`}
|
||||
component="div"
|
||||
className="text-error text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ErrorMessage
|
||||
name={`starter_messages[${index}].description`}
|
||||
component="div"
|
||||
className="text-error text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label small>Message</Label>
|
||||
<SubLabel>
|
||||
The actual message to be sent as the
|
||||
initial user message if a user selects
|
||||
this starter prompt. For example,
|
||||
"Write me an email to a client
|
||||
about a new billing feature we just
|
||||
released."
|
||||
</SubLabel>
|
||||
<Field
|
||||
name={`starter_messages[${index}].message`}
|
||||
className={`
|
||||
<div className="mt-3">
|
||||
<Label small>Message</Label>
|
||||
<SubLabel>
|
||||
The actual message to be sent as the
|
||||
initial user message if a user
|
||||
selects this starter prompt. For
|
||||
example, "Write me an email to
|
||||
a client about a new billing feature
|
||||
we just released."
|
||||
</SubLabel>
|
||||
<Field
|
||||
name={`starter_messages[${index}].message`}
|
||||
className={`
|
||||
border
|
||||
border-border
|
||||
bg-background
|
||||
@ -979,28 +992,29 @@ export function AssistantEditor({
|
||||
px-3
|
||||
mr-4
|
||||
`}
|
||||
as="textarea"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ErrorMessage
|
||||
name={`starter_messages[${index}].message`}
|
||||
component="div"
|
||||
className="text-error text-sm mt-1"
|
||||
as="textarea"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ErrorMessage
|
||||
name={`starter_messages[${index}].message`}
|
||||
component="div"
|
||||
className="text-error text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-auto">
|
||||
<FiX
|
||||
className="my-auto w-10 h-10 cursor-pointer hover:bg-hover rounded p-2"
|
||||
onClick={() =>
|
||||
arrayHelpers.remove(index)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-auto">
|
||||
<FiX
|
||||
className="my-auto w-10 h-10 cursor-pointer hover:bg-hover rounded p-2"
|
||||
onClick={() =>
|
||||
arrayHelpers.remove(index)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
}
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
@ -1025,65 +1039,17 @@ export function AssistantEditor({
|
||||
|
||||
{isPaidEnterpriseFeaturesEnabled &&
|
||||
userGroups &&
|
||||
(!user || user.role === "admin") && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<BooleanFormField
|
||||
small
|
||||
noPadding
|
||||
alignTop
|
||||
name="is_public"
|
||||
label="Is Public?"
|
||||
subtext="If set, this Assistant will be available to all users. If not, only the specified User Groups will be able to access it."
|
||||
/>
|
||||
|
||||
{userGroups &&
|
||||
userGroups.length > 0 &&
|
||||
!values.is_public && (
|
||||
<div>
|
||||
<Text>
|
||||
Select which User Groups should have access to
|
||||
this Assistant.
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{userGroups.map((userGroup) => {
|
||||
const isSelected = values.groups.includes(
|
||||
userGroup.id
|
||||
);
|
||||
return (
|
||||
<Bubble
|
||||
key={userGroup.id}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setFieldValue(
|
||||
"groups",
|
||||
values.groups.filter(
|
||||
(id) => id !== userGroup.id
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setFieldValue("groups", [
|
||||
...values.groups,
|
||||
userGroup.id,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex">
|
||||
<GroupsIcon />
|
||||
<div className="ml-1">
|
||||
{userGroup.name}
|
||||
</div>
|
||||
</div>
|
||||
</Bubble>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
userGroups.length > 0 && (
|
||||
<IsPublicGroupSelector
|
||||
formikProps={{
|
||||
values,
|
||||
isSubmitting,
|
||||
setFieldValue,
|
||||
...formikProps,
|
||||
}}
|
||||
objectName="assistant"
|
||||
enforceGroupSelection={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
@ -1092,7 +1058,7 @@ export function AssistantEditor({
|
||||
color="green"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isRequestSuccessful}
|
||||
>
|
||||
{isUpdate ? "Update!" : "Create!"}
|
||||
</Button>
|
||||
|
@ -5,12 +5,14 @@ import { Persona } from "./interfaces";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CustomCheckbox } from "@/components/CustomCheckbox";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { UniqueIdentifier } from "@dnd-kit/core";
|
||||
import { DraggableTable } from "@/components/table/DraggableTable";
|
||||
import { deletePersona, personaComparator } from "./lib";
|
||||
import { FiEdit2 } from "react-icons/fi";
|
||||
import { TrashIcon } from "@/components/icons/icons";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
import { UserRole, User } from "@/lib/types";
|
||||
|
||||
function PersonaTypeDisplay({ persona }: { persona: Persona }) {
|
||||
if (persona.default_persona) {
|
||||
@ -28,21 +30,67 @@ function PersonaTypeDisplay({ persona }: { persona: Persona }) {
|
||||
return <Text>Personal {persona.owner && <>({persona.owner.email})</>}</Text>;
|
||||
}
|
||||
|
||||
export function PersonasTable({ personas }: { personas: Persona[] }) {
|
||||
const togglePersonaVisibility = async (
|
||||
personaId: number,
|
||||
isVisible: boolean
|
||||
) => {
|
||||
const response = await fetch(`/api/admin/persona/${personaId}/visible`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
is_visible: !isVisible,
|
||||
}),
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
export function PersonasTable({
|
||||
allPersonas,
|
||||
editablePersonas,
|
||||
}: {
|
||||
allPersonas: Persona[];
|
||||
editablePersonas: Persona[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
const availablePersonaIds = new Set(
|
||||
personas.map((persona) => persona.id.toString())
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
const editablePersonaIds = new Set(
|
||||
editablePersonas.map((p) => p.id.toString())
|
||||
);
|
||||
const sortedPersonas = [...personas];
|
||||
sortedPersonas.sort(personaComparator);
|
||||
|
||||
const sortedPersonas = useMemo(() => {
|
||||
const editable = editablePersonas.sort(personaComparator);
|
||||
const nonEditable = allPersonas
|
||||
.filter((p) => !editablePersonaIds.has(p.id.toString()))
|
||||
.sort(personaComparator);
|
||||
return [...editable, ...nonEditable];
|
||||
}, [allPersonas, editablePersonas]);
|
||||
|
||||
const [finalPersonas, setFinalPersonas] = useState<string[]>(
|
||||
sortedPersonas.map((persona) => persona.id.toString())
|
||||
);
|
||||
const finalPersonaValues = finalPersonas
|
||||
.filter((id) => availablePersonaIds.has(id))
|
||||
.filter((id) => new Set(allPersonas.map((p) => p.id.toString())).has(id))
|
||||
.map((id) => {
|
||||
return sortedPersonas.find(
|
||||
(persona) => persona.id.toString() === id
|
||||
@ -82,12 +130,14 @@ export function PersonasTable({ personas }: { personas: Persona[] }) {
|
||||
<Text className="my-2">
|
||||
Assistants will be displayed as options on the Chat / Search interfaces
|
||||
in the order they are displayed below. Assistants marked as hidden will
|
||||
not be displayed.
|
||||
not be displayed. Editable assistants are shown at the top.
|
||||
</Text>
|
||||
|
||||
<DraggableTable
|
||||
headers={["Name", "Description", "Type", "Is Visible", "Delete"]}
|
||||
isAdmin={isAdmin}
|
||||
rows={finalPersonaValues.map((persona) => {
|
||||
const isEditable = editablePersonaIds.has(persona.id.toString());
|
||||
return {
|
||||
id: persona.id.toString(),
|
||||
cells: [
|
||||
@ -116,28 +166,22 @@ export function PersonasTable({ personas }: { personas: Persona[] }) {
|
||||
<div
|
||||
key="is_visible"
|
||||
onClick={async () => {
|
||||
const response = await fetch(
|
||||
`/api/admin/persona/${persona.id}/visible`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
is_visible: !persona.is_visible,
|
||||
}),
|
||||
if (isEditable) {
|
||||
const response = await togglePersonaVisibility(
|
||||
persona.id,
|
||||
persona.is_visible
|
||||
);
|
||||
if (response.ok) {
|
||||
router.refresh();
|
||||
} else {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: `Failed to update persona - ${await response.text()}`,
|
||||
});
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
router.refresh();
|
||||
} else {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: `Failed to update persona - ${await response.text()}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-1 py-0.5 hover:bg-hover-light rounded flex cursor-pointer select-none w-fit"
|
||||
className={`px-1 py-0.5 rounded flex ${isEditable ? "hover:bg-hover cursor-pointer" : ""} select-none w-fit`}
|
||||
>
|
||||
<div className="my-auto w-12">
|
||||
{!persona.is_visible ? (
|
||||
@ -152,7 +196,7 @@ export function PersonasTable({ personas }: { personas: Persona[] }) {
|
||||
</div>,
|
||||
<div key="edit" className="flex">
|
||||
<div className="mx-auto my-auto">
|
||||
{!persona.default_persona ? (
|
||||
{!persona.default_persona && isEditable ? (
|
||||
<div
|
||||
className="hover:bg-hover rounded p-1 cursor-pointer"
|
||||
onClick={async () => {
|
||||
|
@ -9,18 +9,25 @@ import { AssistantsIcon, RobotIcon } from "@/components/icons/icons";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
|
||||
export default async function Page() {
|
||||
const personaResponse = await fetchSS("/admin/persona");
|
||||
const allPersonaResponse = await fetchSS("/admin/persona");
|
||||
const editablePersonaResponse = await fetchSS(
|
||||
"/admin/persona?get_editable=true"
|
||||
);
|
||||
|
||||
if (!personaResponse.ok) {
|
||||
if (!allPersonaResponse.ok || !editablePersonaResponse.ok) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch personas - ${await personaResponse.text()}`}
|
||||
errorMsg={`Failed to fetch personas - ${
|
||||
(await allPersonaResponse.text()) ||
|
||||
(await editablePersonaResponse.text())
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const personas = (await personaResponse.json()) as Persona[];
|
||||
const allPersonas = (await allPersonaResponse.json()) as Persona[];
|
||||
const editablePersonas = (await editablePersonaResponse.json()) as Persona[];
|
||||
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
@ -57,7 +64,10 @@ export default async function Page() {
|
||||
<Divider />
|
||||
|
||||
<Title>Existing Assistants</Title>
|
||||
<PersonasTable personas={personas} />
|
||||
<PersonasTable
|
||||
allPersonas={allPersonas}
|
||||
editablePersonas={editablePersonas}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -4,6 +4,10 @@ export function buildCCPairInfoUrl(ccPairId: string | number) {
|
||||
return `/api/manage/admin/cc-pair/${ccPairId}`;
|
||||
}
|
||||
|
||||
export function buildSimilarCredentialInfoURL(source_type: ValidSources) {
|
||||
return `/api/manage/admin/similar-credentials/${source_type}`;
|
||||
export function buildSimilarCredentialInfoURL(
|
||||
source_type: ValidSources,
|
||||
get_editable: boolean = false
|
||||
) {
|
||||
const base = `/api/manage/admin/similar-credentials/${source_type}`;
|
||||
return get_editable ? `${base}?get_editable=True` : base;
|
||||
}
|
||||
|
@ -132,7 +132,7 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
<SourceIcon iconSize={24} sourceType={ccPair.connector.source} />
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
{ccPair.is_editable_for_current_user && isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@ -150,30 +150,36 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
</div>
|
||||
) : (
|
||||
<h1
|
||||
onClick={() => startEditing()}
|
||||
className="group flex cursor-pointer text-3xl text-emphasis gap-x-2 items-center font-bold"
|
||||
onClick={() =>
|
||||
ccPair.is_editable_for_current_user && startEditing()
|
||||
}
|
||||
className={`group flex ${ccPair.is_editable_for_current_user ? "cursor-pointer" : ""} text-3xl text-emphasis gap-x-2 items-center font-bold`}
|
||||
>
|
||||
{ccPair.name}
|
||||
<EditIcon className="group-hover:visible invisible" />
|
||||
{ccPair.is_editable_for_current_user && (
|
||||
<EditIcon className="group-hover:visible invisible" />
|
||||
)}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex gap-x-2">
|
||||
{!CONNECTOR_TYPES_THAT_CANT_REINDEX.includes(
|
||||
ccPair.connector.source
|
||||
) && (
|
||||
<ReIndexButton
|
||||
ccPairId={ccPair.id}
|
||||
connectorId={ccPair.connector.id}
|
||||
credentialId={ccPair.credential.id}
|
||||
isDisabled={
|
||||
ccPair.status === ConnectorCredentialPairStatus.PAUSED
|
||||
}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
)}
|
||||
{!isDeleting && <ModifyStatusButtonCluster ccPair={ccPair} />}
|
||||
</div>
|
||||
{ccPair.is_editable_for_current_user && (
|
||||
<div className="ml-auto flex gap-x-2">
|
||||
{!CONNECTOR_TYPES_THAT_CANT_REINDEX.includes(
|
||||
ccPair.connector.source
|
||||
) && (
|
||||
<ReIndexButton
|
||||
ccPairId={ccPair.id}
|
||||
connectorId={ccPair.connector.id}
|
||||
credentialId={ccPair.credential.id}
|
||||
isDisabled={
|
||||
ccPair.status === ConnectorCredentialPairStatus.PAUSED
|
||||
}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
)}
|
||||
{!isDeleting && <ModifyStatusButtonCluster ccPair={ccPair} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CCPairStatus
|
||||
status={lastIndexAttempt?.status || "not_started"}
|
||||
@ -184,19 +190,27 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
Total Documents Indexed:{" "}
|
||||
<b className="text-emphasis">{totalDocsIndexed}</b>
|
||||
</div>
|
||||
{credentialTemplates[ccPair.connector.source] && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Title className="mb-2">Credentials</Title>
|
||||
|
||||
<CredentialSection
|
||||
ccPair={ccPair}
|
||||
sourceType={ccPair.connector.source}
|
||||
refresh={() => refresh()}
|
||||
/>
|
||||
</>
|
||||
{!ccPair.is_editable_for_current_user && (
|
||||
<div className="text-sm mt-2 text-neutral-500 italic">
|
||||
{ccPair.is_public
|
||||
? "Public connectors are not editable by curators."
|
||||
: "This connector belongs to groups where you don't have curator permissions, so it's not editable."}
|
||||
</div>
|
||||
)}
|
||||
{credentialTemplates[ccPair.connector.source] &&
|
||||
ccPair.is_editable_for_current_user && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Title className="mb-2">Credentials</Title>
|
||||
|
||||
<CredentialSection
|
||||
ccPair={ccPair}
|
||||
sourceType={ccPair.connector.source}
|
||||
refresh={() => refresh()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<ConfigDisplay
|
||||
connectorSpecificConfig={ccPair.connector.connector_specific_config}
|
||||
@ -222,7 +236,9 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
<Divider />
|
||||
<div className="flex mt-4">
|
||||
<div className="mx-auto">
|
||||
<DeletionButton ccPair={ccPair} />
|
||||
{ccPair.is_editable_for_current_user && (
|
||||
<DeletionButton ccPair={ccPair} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -17,4 +17,6 @@ export interface CCPairFullInfo {
|
||||
credential: Credential<any>;
|
||||
index_attempts: IndexAttemptSnapshot[];
|
||||
latest_deletion_attempt: DeletionAttemptSnapshot | null;
|
||||
is_public: boolean;
|
||||
is_editable_for_current_user: boolean;
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useFormContext } from "@/components/context/FormContext";
|
||||
import { getSourceDisplayName } from "@/lib/sources";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { submitConnector } from "@/components/admin/connectors/ConnectorForm";
|
||||
import { deleteCredential, linkCredential } from "@/lib/credential";
|
||||
import { submitFiles } from "./pages/utils/files";
|
||||
@ -59,6 +59,11 @@ export default function AddConnector({
|
||||
errorHandlingFetcher,
|
||||
{ refreshInterval: 5000 }
|
||||
);
|
||||
const { data: editableCredentials } = useSWR<Credential<any>[]>(
|
||||
buildSimilarCredentialInfoURL(connector, true),
|
||||
errorHandlingFetcher,
|
||||
{ refreshInterval: 5000 }
|
||||
);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
|
||||
const credentialTemplate = credentialTemplates[connector];
|
||||
@ -95,6 +100,7 @@ export default function AddConnector({
|
||||
const [pruneFreq, setPruneFreq] = useState<number>(defaultPrune);
|
||||
const [indexingStart, setIndexingStart] = useState<Date | null>(null);
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [groups, setGroups] = useState<number[]>([]);
|
||||
const [createConnectorToggle, setCreateConnectorToggle] = useState(false);
|
||||
const formRef = useRef<FormikProps<any>>(null);
|
||||
const [advancedFormPageState, setAdvancedFormPageState] = useState(true);
|
||||
@ -110,7 +116,9 @@ export default function AddConnector({
|
||||
const { liveGmailCredential } = useGmailCredentials();
|
||||
|
||||
const credentialActivated =
|
||||
liveGDriveCredential || liveGmailCredential || currentCredential;
|
||||
(connector === "google_drive" && liveGDriveCredential) ||
|
||||
(connector === "gmail" && liveGmailCredential) ||
|
||||
currentCredential;
|
||||
|
||||
const noCredentials = credentialTemplate == null;
|
||||
if (noCredentials && 1 != formStep) {
|
||||
@ -170,7 +178,8 @@ export default function AddConnector({
|
||||
setSelectedFiles,
|
||||
name,
|
||||
AdvancedConfig,
|
||||
isPublic
|
||||
isPublic,
|
||||
groups
|
||||
);
|
||||
if (response) {
|
||||
setTimeout(() => {
|
||||
@ -189,6 +198,8 @@ export default function AddConnector({
|
||||
refresh_freq: refreshFreq * 60 || null,
|
||||
prune_freq: pruneFreq * 60 * 60 * 24 || null,
|
||||
indexing_start: indexingStart,
|
||||
is_public: isPublic,
|
||||
groups: groups,
|
||||
},
|
||||
undefined,
|
||||
credentialActivated ? false : true,
|
||||
@ -218,7 +229,8 @@ export default function AddConnector({
|
||||
response.id,
|
||||
credential?.id!,
|
||||
name,
|
||||
isPublic
|
||||
isPublic,
|
||||
groups
|
||||
);
|
||||
if (linkCredentialResponse.ok) {
|
||||
setPopup({
|
||||
@ -247,7 +259,7 @@ export default function AddConnector({
|
||||
};
|
||||
|
||||
const displayName = getSourceDisplayName(connector) || connector;
|
||||
if (!credentials) {
|
||||
if (!credentials || !editableCredentials) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@ -350,6 +362,7 @@ export default function AddConnector({
|
||||
source={connector}
|
||||
defaultedCredential={currentCredential!}
|
||||
credentials={credentials}
|
||||
editableCredentials={editableCredentials}
|
||||
onDeleteCredential={onDeleteCredential}
|
||||
onSwitch={onSwap}
|
||||
/>
|
||||
@ -411,6 +424,8 @@ export default function AddConnector({
|
||||
setName={setName}
|
||||
config={configuration}
|
||||
isPublic={isPublic}
|
||||
groups={groups}
|
||||
setGroups={setGroups}
|
||||
defaultValues={values}
|
||||
initialName={name}
|
||||
onFormStatusChange={handleFormStatusChange}
|
||||
@ -430,7 +445,10 @@ export default function AddConnector({
|
||||
)}
|
||||
<button
|
||||
className="enabled:cursor-pointer ml-auto disabled:bg-accent/50 disabled:cursor-not-allowed bg-accent flex mx-auto gap-x-1 items-center text-white py-2.5 px-3.5 text-sm font-regular rounded-sm"
|
||||
disabled={!isFormValid}
|
||||
disabled={
|
||||
!isFormValid ||
|
||||
(connector == "file" && selectedFiles.length == 0)
|
||||
}
|
||||
onClick={async () => {
|
||||
await createConnector();
|
||||
}}
|
||||
@ -464,10 +482,10 @@ export default function AddConnector({
|
||||
key={advancedFormPageState ? 0 : 1}
|
||||
setIndexingStart={setIndexingStart}
|
||||
indexingStart={indexingStart}
|
||||
currentPruneFreq={pruneFreq}
|
||||
currentRefreshFreq={refreshFreq}
|
||||
setPruneFreq={setPruneFreq}
|
||||
currentPruneFreq={pruneFreq}
|
||||
setRefreshFreq={setRefreshFreq}
|
||||
currentRefreshFreq={refreshFreq}
|
||||
ref={formRef}
|
||||
/>
|
||||
|
||||
|
@ -15,12 +15,12 @@ interface AdvancedFormPageProps {
|
||||
const AdvancedFormPage = forwardRef<FormikProps<any>, AdvancedFormPageProps>(
|
||||
(
|
||||
{
|
||||
setIndexingStart,
|
||||
indexingStart,
|
||||
setRefreshFreq,
|
||||
currentRefreshFreq,
|
||||
setPruneFreq,
|
||||
currentPruneFreq,
|
||||
currentRefreshFreq,
|
||||
indexingStart,
|
||||
setIndexingStart,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
|
@ -1,7 +1,9 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { Formik, Form, Field, FieldArray } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { FaPlus } from "react-icons/fa";
|
||||
import { useUserGroups } from "@/lib/hooks";
|
||||
import { UserGroup, User, UserRole } from "@/lib/types";
|
||||
import { EditingValue } from "@/components/credentials/EditingValue";
|
||||
import { Divider } from "@tremor/react";
|
||||
import CredentialSubText from "@/components/credentials/CredentialFields";
|
||||
@ -10,6 +12,9 @@ import { FileUpload } from "@/components/admin/connectors/FileUpload";
|
||||
import { ConnectionConfiguration } from "@/lib/connectors/connectors";
|
||||
import { useFormContext } from "@/components/context/FormContext";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { Text } from "@tremor/react";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
import { FiUsers } from "react-icons/fi";
|
||||
|
||||
export interface DynamicConnectionFormProps {
|
||||
config: ConnectionConfiguration;
|
||||
@ -21,6 +26,8 @@ export interface DynamicConnectionFormProps {
|
||||
setName: Dispatch<SetStateAction<string>>;
|
||||
updateValues: (field: string, value: any) => void;
|
||||
isPublic: boolean;
|
||||
groups: number[];
|
||||
setGroups: Dispatch<SetStateAction<number[]>>;
|
||||
onFormStatusChange: (isValid: boolean) => void; // New prop
|
||||
}
|
||||
|
||||
@ -33,11 +40,41 @@ const DynamicConnectionForm: React.FC<DynamicConnectionFormProps> = ({
|
||||
setSelectedFiles,
|
||||
isPublic,
|
||||
setIsPublic,
|
||||
groups,
|
||||
setGroups,
|
||||
initialName,
|
||||
onFormStatusChange,
|
||||
}) => {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { setAllowAdvanced } = useFormContext();
|
||||
const { data: userGroups, isLoading: userGroupsIsLoading } = useUserGroups();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
const userIsAdmin = user.role === UserRole.ADMIN;
|
||||
setIsAdmin(userIsAdmin);
|
||||
if (!userIsAdmin) {
|
||||
setIsPublic(false);
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, [setIsPublic]);
|
||||
|
||||
const initialValues = {
|
||||
name: initialName || "",
|
||||
groups: [], // Initialize groups as an empty array
|
||||
...(defaultValues ||
|
||||
config.values.reduce(
|
||||
(acc, field, ind) => {
|
||||
@ -55,9 +92,6 @@ const DynamicConnectionForm: React.FC<DynamicConnectionFormProps> = ({
|
||||
{} as Record<string, any>
|
||||
)),
|
||||
};
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const { setAllowAdvanced } = useFormContext();
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required("Connector Name is required"),
|
||||
@ -292,18 +326,108 @@ const DynamicConnectionForm: React.FC<DynamicConnectionFormProps> = ({
|
||||
{isPaidEnterpriseFeaturesEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
{isAdmin && (
|
||||
<EditingValue
|
||||
description={`If set, then documents indexed by this connector will be visible to all users. If turned off, then only users who explicitly have been given access to the documents (e.g. through a User Group) will have access`}
|
||||
optional
|
||||
setFieldValue={(field: string, value: boolean) => {
|
||||
setIsPublic(value);
|
||||
if (value) {
|
||||
setGroups([]); // Clear groups when setting to public
|
||||
}
|
||||
}}
|
||||
type={"checkbox"}
|
||||
label={"Documents are Public?"}
|
||||
name={"public"}
|
||||
currentValue={isPublic}
|
||||
/>
|
||||
)}
|
||||
{userGroups &&
|
||||
(!isAdmin || (!isPublic && userGroups.length > 0)) && (
|
||||
<div>
|
||||
<div className="flex gap-x-2 items-center">
|
||||
<div className="block font-medium text-base">
|
||||
Assign group access for this Connector
|
||||
</div>
|
||||
</div>
|
||||
<Text className="mb-3">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
This Connector will be visible/accessible by the
|
||||
groups selected below
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Curators must select one or more groups to give
|
||||
access to this Connector
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<FieldArray
|
||||
name="groups"
|
||||
render={() => (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{!userGroupsIsLoading &&
|
||||
userGroups.map((userGroup: UserGroup) => {
|
||||
const isSelected =
|
||||
groups?.includes(userGroup.id) ||
|
||||
(!isAdmin && userGroups.length === 1);
|
||||
|
||||
<EditingValue
|
||||
description={`If set, then documents indexed by this connector will be visible to all users. If turned off, then only users who explicitly have been given access to the documents (e.g. through a User Group) will have access`}
|
||||
optional
|
||||
setFieldValue={(field: string, value: boolean) =>
|
||||
setIsPublic(value)
|
||||
}
|
||||
type={"checkbox"}
|
||||
label={"Documents are Public?"}
|
||||
name={"public"}
|
||||
currentValue={isPublic}
|
||||
/>
|
||||
// Auto-select the only group for non-admin users
|
||||
if (
|
||||
!isAdmin &&
|
||||
userGroups.length === 1 &&
|
||||
groups.length === 0
|
||||
) {
|
||||
setGroups([userGroup.id]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={userGroup.id}
|
||||
className={`
|
||||
px-3
|
||||
py-1
|
||||
rounded-lg
|
||||
border
|
||||
border-border
|
||||
w-fit
|
||||
flex
|
||||
cursor-pointer
|
||||
${isSelected ? "bg-background-strong" : "hover:bg-hover"}
|
||||
`}
|
||||
onClick={() => {
|
||||
if (setGroups) {
|
||||
if (
|
||||
isSelected &&
|
||||
(isAdmin || userGroups.length > 1)
|
||||
) {
|
||||
setGroups(
|
||||
groups?.filter(
|
||||
(id) => id !== userGroup.id
|
||||
) || []
|
||||
);
|
||||
} else if (!isSelected) {
|
||||
setGroups([
|
||||
...(groups || []),
|
||||
userGroup.id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="my-auto flex">
|
||||
<FiUsers className="my-auto mr-2" />{" "}
|
||||
{userGroup.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
@ -147,12 +147,14 @@ interface DriveJsonUploadSectionProps {
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
appCredentialData?: { client_id: string };
|
||||
serviceAccountCredentialData?: { service_account_email: string };
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const DriveJsonUploadSection = ({
|
||||
setPopup,
|
||||
appCredentialData,
|
||||
serviceAccountCredentialData,
|
||||
isAdmin,
|
||||
}: DriveJsonUploadSectionProps) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
@ -165,38 +167,48 @@ export const DriveJsonUploadSection = ({
|
||||
{serviceAccountCredentialData.service_account_email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 mb-1">
|
||||
If you want to update these credentials, delete the existing
|
||||
credentials through the button below, and then upload a new
|
||||
credentials JSON.
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const response = await fetch(
|
||||
"/api/manage/admin/connector/google-drive/service-account-key",
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
mutate(
|
||||
"/api/manage/admin/connector/google-drive/service-account-key"
|
||||
);
|
||||
setPopup({
|
||||
message: "Successfully deleted service account key",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = await response.text();
|
||||
setPopup({
|
||||
message: `Failed to delete service account key - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<div className="mt-4 mb-1">
|
||||
If you want to update these credentials, delete the existing
|
||||
credentials through the button below, and then upload a new
|
||||
credentials JSON.
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const response = await fetch(
|
||||
"/api/manage/admin/connector/google-drive/service-account-key",
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
mutate(
|
||||
"/api/manage/admin/connector/google-drive/service-account-key"
|
||||
);
|
||||
setPopup({
|
||||
message: "Successfully deleted service account key",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = await response.text();
|
||||
setPopup({
|
||||
message: `Failed to delete service account key - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 mb-1">
|
||||
To change these credentials, please contact an administrator.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -242,6 +254,17 @@ export const DriveJsonUploadSection = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm mb-2">
|
||||
Curators are unable to set up the google drive credentials. To add a
|
||||
Google Drive connector, please contact an administrator.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm mb-2">
|
||||
|
@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import useSWR from "swr";
|
||||
import { FetchError, errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { LoadingAnimation } from "@/components/Loading";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { ConnectorIndexingStatus } from "@/lib/types";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
import { User, UserRole } from "@/lib/types";
|
||||
import { usePublicCredentials } from "@/lib/hooks";
|
||||
import { Title } from "@tremor/react";
|
||||
import { DriveJsonUploadSection, DriveOAuthSection } from "./Credential";
|
||||
@ -18,6 +21,24 @@ import {
|
||||
import { GoogleDriveConfig } from "@/lib/connectors/connectors";
|
||||
|
||||
const GDriveMain = ({}: {}) => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
const {
|
||||
data: appCredentialData,
|
||||
isLoading: isAppCredentialLoading,
|
||||
@ -119,22 +140,27 @@ const GDriveMain = ({}: {}) => {
|
||||
setPopup={setPopup}
|
||||
appCredentialData={appCredentialData}
|
||||
serviceAccountCredentialData={serviceAccountKeyData}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
<Title className="mb-2 mt-6 ml-auto mr-auto">
|
||||
Step 2: Authenticate with Danswer
|
||||
</Title>
|
||||
<DriveOAuthSection
|
||||
setPopup={setPopup}
|
||||
refreshCredentials={refreshCredentials}
|
||||
googleDrivePublicCredential={googleDrivePublicCredential}
|
||||
googleDriveServiceAccountCredential={
|
||||
googleDriveServiceAccountCredential
|
||||
}
|
||||
appCredentialData={appCredentialData}
|
||||
serviceAccountKeyData={serviceAccountKeyData}
|
||||
connectorExists={googleDriveConnectorIndexingStatuses.length > 0}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Title className="mb-2 mt-6 ml-auto mr-auto">
|
||||
Step 2: Authenticate with Danswer
|
||||
</Title>
|
||||
<DriveOAuthSection
|
||||
setPopup={setPopup}
|
||||
refreshCredentials={refreshCredentials}
|
||||
googleDrivePublicCredential={googleDrivePublicCredential}
|
||||
googleDriveServiceAccountCredential={
|
||||
googleDriveServiceAccountCredential
|
||||
}
|
||||
appCredentialData={appCredentialData}
|
||||
serviceAccountKeyData={serviceAccountKeyData}
|
||||
connectorExists={googleDriveConnectorIndexingStatuses.length > 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -145,12 +145,14 @@ interface DriveJsonUploadSectionProps {
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
appCredentialData?: { client_id: string };
|
||||
serviceAccountCredentialData?: { service_account_email: string };
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const GmailJsonUploadSection = ({
|
||||
setPopup,
|
||||
appCredentialData,
|
||||
serviceAccountCredentialData,
|
||||
isAdmin,
|
||||
}: DriveJsonUploadSectionProps) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
@ -163,36 +165,48 @@ export const GmailJsonUploadSection = ({
|
||||
{serviceAccountCredentialData.service_account_email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 mb-1">
|
||||
If you want to update these credentials, delete the existing
|
||||
credentials through the button below, and then upload a new
|
||||
credentials JSON.
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const response = await fetch(
|
||||
"/api/manage/admin/connector/gmail/service-account-key",
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
mutate("/api/manage/admin/connector/gmail/service-account-key");
|
||||
setPopup({
|
||||
message: "Successfully deleted service account key",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = await response.text();
|
||||
setPopup({
|
||||
message: `Failed to delete service account key - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<div className="mt-4 mb-1">
|
||||
If you want to update these credentials, delete the existing
|
||||
credentials through the button below, and then upload a new
|
||||
credentials JSON.
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const response = await fetch(
|
||||
"/api/manage/admin/connector/gmail/service-account-key",
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
mutate(
|
||||
"/api/manage/admin/connector/gmail/service-account-key"
|
||||
);
|
||||
setPopup({
|
||||
message: "Successfully deleted service account key",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = await response.text();
|
||||
setPopup({
|
||||
message: `Failed to delete service account key - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 mb-1">
|
||||
To change these credentials, please contact an administrator.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -238,6 +252,17 @@ export const GmailJsonUploadSection = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm mb-2">
|
||||
Curators are unable to set up the Gmail credentials. To add a Gmail
|
||||
connector, please contact an administrator.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm mb-2">
|
||||
|
@ -5,6 +5,8 @@ import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { LoadingAnimation } from "@/components/Loading";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { ConnectorIndexingStatus } from "@/lib/types";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
import { User, UserRole } from "@/lib/types";
|
||||
import {
|
||||
Credential,
|
||||
GmailCredentialJson,
|
||||
@ -14,8 +16,27 @@ import { GmailOAuthSection, GmailJsonUploadSection } from "./Credential";
|
||||
import { usePublicCredentials } from "@/lib/hooks";
|
||||
import { Title } from "@tremor/react";
|
||||
import { GmailConfig } from "@/lib/connectors/connectors";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const GmailMain = () => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
const {
|
||||
data: appCredentialData,
|
||||
isLoading: isAppCredentialLoading,
|
||||
@ -126,20 +147,25 @@ export const GmailMain = () => {
|
||||
setPopup={setPopup}
|
||||
appCredentialData={appCredentialData}
|
||||
serviceAccountCredentialData={serviceAccountKeyData}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
<Title className="mb-2 mt-6 ml-auto mr-auto">
|
||||
Step 2: Authenticate with Danswer
|
||||
</Title>
|
||||
<GmailOAuthSection
|
||||
setPopup={setPopup}
|
||||
refreshCredentials={refreshCredentials}
|
||||
gmailPublicCredential={gmailPublicCredential}
|
||||
gmailServiceAccountCredential={gmailServiceAccountCredential}
|
||||
appCredentialData={appCredentialData}
|
||||
serviceAccountKeyData={serviceAccountKeyData}
|
||||
connectorExists={gmailConnectorIndexingStatuses.length > 0}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Title className="mb-2 mt-6 ml-auto mr-auto">
|
||||
Step 2: Authenticate with Danswer
|
||||
</Title>
|
||||
<GmailOAuthSection
|
||||
setPopup={setPopup}
|
||||
refreshCredentials={refreshCredentials}
|
||||
gmailPublicCredential={gmailPublicCredential}
|
||||
gmailServiceAccountCredential={gmailServiceAccountCredential}
|
||||
appCredentialData={appCredentialData}
|
||||
serviceAccountKeyData={serviceAccountKeyData}
|
||||
connectorExists={gmailConnectorIndexingStatuses.length > 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -10,7 +10,8 @@ export const submitFiles = async (
|
||||
setSelectedFiles: (files: File[]) => void,
|
||||
name: string,
|
||||
advancedConfig: AdvancedConfig,
|
||||
isPublic: boolean
|
||||
isPublic: boolean,
|
||||
groups?: number[]
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
|
||||
@ -43,6 +44,8 @@ export const submitFiles = async (
|
||||
refresh_freq: null,
|
||||
prune_freq: null,
|
||||
indexing_start: null,
|
||||
is_public: isPublic,
|
||||
groups: groups,
|
||||
});
|
||||
if (connectorErrorMsg || !connector) {
|
||||
setPopup({
|
||||
@ -60,6 +63,8 @@ export const submitFiles = async (
|
||||
credential_json: {},
|
||||
admin_public: true,
|
||||
source: "file",
|
||||
curator_public: isPublic,
|
||||
groups: groups,
|
||||
name,
|
||||
});
|
||||
if (!createCredentialResponse.ok) {
|
||||
@ -77,7 +82,8 @@ export const submitFiles = async (
|
||||
connector.id,
|
||||
credentialId,
|
||||
name,
|
||||
isPublic
|
||||
isPublic,
|
||||
groups
|
||||
);
|
||||
if (!credentialResponse.ok) {
|
||||
const credentialResponseJson = await credentialResponse.json();
|
||||
|
@ -3,16 +3,17 @@
|
||||
import { ArrayHelpers, FieldArray, Form, Formik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { createDocumentSet, updateDocumentSet } from "./lib";
|
||||
import { ConnectorIndexingStatus, DocumentSet, UserGroup } from "@/lib/types";
|
||||
import {
|
||||
BooleanFormField,
|
||||
TextFormField,
|
||||
} from "@/components/admin/connectors/Field";
|
||||
createDocumentSet,
|
||||
updateDocumentSet,
|
||||
DocumentSetCreationRequest,
|
||||
} from "./lib";
|
||||
import { ConnectorIndexingStatus, DocumentSet, UserGroup } from "@/lib/types";
|
||||
import { TextFormField } from "@/components/admin/connectors/Field";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
import { Button, Divider, Text } from "@tremor/react";
|
||||
import { FiUsers } from "react-icons/fi";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { IsPublicGroupSelector } from "@/components/IsPublicGroupSelector";
|
||||
|
||||
interface SetCreationPopupProps {
|
||||
ccPairs: ConnectorIndexingStatus<any, any>[];
|
||||
@ -35,22 +36,17 @@ export const DocumentSetCreationForm = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Formik
|
||||
<Formik<DocumentSetCreationRequest>
|
||||
initialValues={{
|
||||
name: existingDocumentSet ? existingDocumentSet.name : "",
|
||||
description: existingDocumentSet
|
||||
? existingDocumentSet.description
|
||||
: "",
|
||||
cc_pair_ids: existingDocumentSet
|
||||
? existingDocumentSet.cc_pair_descriptors.map(
|
||||
(ccPairDescriptor) => {
|
||||
return ccPairDescriptor.id;
|
||||
}
|
||||
)
|
||||
: ([] as number[]),
|
||||
is_public: existingDocumentSet ? existingDocumentSet.is_public : true,
|
||||
users: existingDocumentSet ? existingDocumentSet.users : [],
|
||||
groups: existingDocumentSet ? existingDocumentSet.groups : [],
|
||||
name: existingDocumentSet?.name ?? "",
|
||||
description: existingDocumentSet?.description ?? "",
|
||||
cc_pair_ids:
|
||||
existingDocumentSet?.cc_pair_descriptors.map(
|
||||
(ccPairDescriptor) => ccPairDescriptor.id
|
||||
) ?? [],
|
||||
is_public: existingDocumentSet?.is_public ?? true,
|
||||
users: existingDocumentSet?.users ?? [],
|
||||
groups: existingDocumentSet?.groups ?? [],
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
name: Yup.string().required("Please enter a name for the set"),
|
||||
@ -74,6 +70,7 @@ export const DocumentSetCreationForm = ({
|
||||
response = await updateDocumentSet({
|
||||
id: existingDocumentSet.id,
|
||||
...processedValues,
|
||||
users: processedValues.users,
|
||||
});
|
||||
} else {
|
||||
response = await createDocumentSet(processedValues);
|
||||
@ -98,7 +95,7 @@ export const DocumentSetCreationForm = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values }) => (
|
||||
{(props) => (
|
||||
<Form>
|
||||
<TextFormField
|
||||
name="name"
|
||||
@ -128,7 +125,9 @@ export const DocumentSetCreationForm = ({
|
||||
render={(arrayHelpers: ArrayHelpers) => (
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
{ccPairs.map((ccPair) => {
|
||||
const ind = values.cc_pair_ids.indexOf(ccPair.cc_pair_id);
|
||||
const ind = props.values.cc_pair_ids.indexOf(
|
||||
ccPair.cc_pair_id
|
||||
);
|
||||
let isSelected = ind !== -1;
|
||||
return (
|
||||
<div
|
||||
@ -174,89 +173,15 @@ export const DocumentSetCreationForm = ({
|
||||
{isPaidEnterpriseFeaturesEnabled &&
|
||||
userGroups &&
|
||||
userGroups.length > 0 && (
|
||||
<div>
|
||||
<Divider />
|
||||
|
||||
<BooleanFormField
|
||||
name="is_public"
|
||||
label="Is Public?"
|
||||
subtext={
|
||||
<>
|
||||
If the document set is public, then it will be visible
|
||||
to <b>all users</b>. If it is not public, then only
|
||||
users in the specified groups will be able to see it.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<h2 className="mb-1 font-medium text-base">
|
||||
Groups with Access
|
||||
</h2>
|
||||
{!values.is_public ? (
|
||||
<>
|
||||
<Text className="mb-3">
|
||||
If any groups are specified, then this Document Set will
|
||||
only be visible to the specified groups. If no groups
|
||||
are specified, then the Document Set will be visible to
|
||||
all users.
|
||||
</Text>
|
||||
<FieldArray
|
||||
name="groups"
|
||||
render={(arrayHelpers: ArrayHelpers) => (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{userGroups.map((userGroup) => {
|
||||
const ind = values.groups.indexOf(userGroup.id);
|
||||
let isSelected = ind !== -1;
|
||||
return (
|
||||
<div
|
||||
key={userGroup.id}
|
||||
className={
|
||||
`
|
||||
px-3
|
||||
py-1
|
||||
rounded-lg
|
||||
border
|
||||
border-border
|
||||
w-fit
|
||||
flex
|
||||
cursor-pointer ` +
|
||||
(isSelected
|
||||
? " bg-background-strong"
|
||||
: " hover:bg-hover")
|
||||
}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
arrayHelpers.remove(ind);
|
||||
} else {
|
||||
arrayHelpers.push(userGroup.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="my-auto flex">
|
||||
<FiUsers className="my-auto mr-2" />{" "}
|
||||
{userGroup.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Text>
|
||||
This Document Set is public, so this does not apply. If
|
||||
you want to control which user groups see this Document
|
||||
Set, mark it as non-public!
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<IsPublicGroupSelector
|
||||
formikProps={props}
|
||||
objectName="document set"
|
||||
/>
|
||||
)}
|
||||
<div className="flex mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={props.isSubmitting}
|
||||
className="w-64 mx-auto"
|
||||
>
|
||||
{isUpdate ? "Update!" : "Create!"}
|
||||
|
@ -3,19 +3,19 @@ import { DocumentSet } from "@/lib/types";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
const DOCUMENT_SETS_URL = "/api/manage/admin/document-set";
|
||||
const GET_EDITABLE_DOCUMENT_SETS_URL =
|
||||
"/api/manage/admin/document-set?get_editable=true";
|
||||
|
||||
export function refreshDocumentSets() {
|
||||
mutate(DOCUMENT_SETS_URL);
|
||||
}
|
||||
|
||||
export function useDocumentSets() {
|
||||
const swrResponse = useSWR<DocumentSet[]>(
|
||||
DOCUMENT_SETS_URL,
|
||||
errorHandlingFetcher,
|
||||
{
|
||||
refreshInterval: 5000, // 5 seconds
|
||||
}
|
||||
);
|
||||
export function useDocumentSets(getEditable: boolean = false) {
|
||||
const url = getEditable ? GET_EDITABLE_DOCUMENT_SETS_URL : DOCUMENT_SETS_URL;
|
||||
|
||||
const swrResponse = useSWR<DocumentSet[]>(url, errorHandlingFetcher, {
|
||||
refreshInterval: 5000, // 5 seconds
|
||||
});
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
|
@ -1,4 +1,4 @@
|
||||
interface DocumentSetCreationRequest {
|
||||
export interface DocumentSetCreationRequest {
|
||||
name: string;
|
||||
description: string;
|
||||
cc_pair_ids: number[];
|
||||
|
@ -16,7 +16,9 @@ import {
|
||||
} from "@tremor/react";
|
||||
import { useConnectorCredentialIndexingStatus } from "@/lib/hooks";
|
||||
import { ConnectorIndexingStatus, DocumentSet } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
import { User, UserRole } from "@/lib/types";
|
||||
import { useDocumentSets } from "./hooks";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
import { deleteDocumentSet } from "./lib";
|
||||
@ -28,6 +30,8 @@ import {
|
||||
FiCheckCircle,
|
||||
FiClock,
|
||||
FiEdit2,
|
||||
FiLock,
|
||||
FiUnlock,
|
||||
} from "react-icons/fi";
|
||||
import { DeleteButton } from "@/components/DeleteButton";
|
||||
import Link from "next/link";
|
||||
@ -35,10 +39,25 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
const numToDisplay = 50;
|
||||
|
||||
const EditRow = ({ documentSet }: { documentSet: DocumentSet }) => {
|
||||
const EditRow = ({
|
||||
documentSet,
|
||||
isEditable,
|
||||
}: {
|
||||
documentSet: DocumentSet;
|
||||
isEditable: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [isSyncingTooltipOpen, setIsSyncingTooltipOpen] = useState(false);
|
||||
|
||||
if (!isEditable) {
|
||||
return (
|
||||
<div className="text-emphasis font-medium my-auto p-1">
|
||||
{documentSet.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex">
|
||||
{isSyncingTooltipOpen && (
|
||||
@ -79,15 +98,36 @@ interface DocumentFeedbackTableProps {
|
||||
documentSets: DocumentSet[];
|
||||
ccPairs: ConnectorIndexingStatus<any, any>[];
|
||||
refresh: () => void;
|
||||
refreshEditable: () => void;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
editableDocumentSets: DocumentSet[];
|
||||
}
|
||||
|
||||
const DocumentSetTable = ({
|
||||
documentSets,
|
||||
editableDocumentSets,
|
||||
refresh,
|
||||
refreshEditable,
|
||||
setPopup,
|
||||
}: DocumentFeedbackTableProps) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
// sort by name for consistent ordering
|
||||
documentSets.sort((a, b) => {
|
||||
@ -100,6 +140,13 @@ const DocumentSetTable = ({
|
||||
}
|
||||
});
|
||||
|
||||
const sortedDocumentSets = [
|
||||
...editableDocumentSets,
|
||||
...documentSets.filter(
|
||||
(ds) => !editableDocumentSets.some((eds) => eds.id === ds.id)
|
||||
),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title>Existing Document Sets</Title>
|
||||
@ -109,18 +156,25 @@ const DocumentSetTable = ({
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Connectors</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Public</TableHeaderCell>
|
||||
<TableHeaderCell>Delete</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{documentSets
|
||||
{sortedDocumentSets
|
||||
.slice((page - 1) * numToDisplay, page * numToDisplay)
|
||||
.map((documentSet) => {
|
||||
const isEditable = editableDocumentSets.some(
|
||||
(eds) => eds.id === documentSet.id
|
||||
);
|
||||
return (
|
||||
<TableRow key={documentSet.id}>
|
||||
<TableCell className="whitespace-normal break-all">
|
||||
<div className="flex gap-x-1 text-emphasis">
|
||||
<EditRow documentSet={documentSet} />
|
||||
<EditRow
|
||||
documentSet={documentSet}
|
||||
isEditable={isEditable}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@ -165,26 +219,50 @@ const DocumentSetTable = ({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
const response = await deleteDocumentSet(
|
||||
documentSet.id
|
||||
);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: `Document set "${documentSet.name}" scheduled for deletion`,
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = (await response.json()).detail;
|
||||
setPopup({
|
||||
message: `Failed to schedule document set for deletion - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
{documentSet.is_public ? (
|
||||
<Badge
|
||||
size="md"
|
||||
color={isEditable ? "green" : "gray"}
|
||||
icon={FiUnlock}
|
||||
>
|
||||
Public
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
size="md"
|
||||
color={isEditable ? "blue" : "gray"}
|
||||
icon={FiLock}
|
||||
>
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditable ? (
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
const response = await deleteDocumentSet(
|
||||
documentSet.id
|
||||
);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: `Document set "${documentSet.name}" scheduled for deletion`,
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = (await response.json()).detail;
|
||||
setPopup({
|
||||
message: `Failed to schedule document set for deletion - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
refreshEditable();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@ -195,7 +273,7 @@ const DocumentSetTable = ({
|
||||
<div className="mt-3 flex">
|
||||
<div className="mx-auto">
|
||||
<PageSelector
|
||||
totalPages={Math.ceil(documentSets.length / numToDisplay)}
|
||||
totalPages={Math.ceil(sortedDocumentSets.length / numToDisplay)}
|
||||
currentPage={page}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
@ -213,6 +291,12 @@ const Main = () => {
|
||||
error: documentSetsError,
|
||||
refreshDocumentSets,
|
||||
} = useDocumentSets();
|
||||
const {
|
||||
data: editableDocumentSets,
|
||||
isLoading: isEditableDocumentSetsLoading,
|
||||
error: editableDocumentSetsError,
|
||||
refreshDocumentSets: refreshEditableDocumentSets,
|
||||
} = useDocumentSets(true);
|
||||
|
||||
const {
|
||||
data: ccPairs,
|
||||
@ -220,7 +304,11 @@ const Main = () => {
|
||||
error: ccPairsError,
|
||||
} = useConnectorCredentialIndexingStatus();
|
||||
|
||||
if (isDocumentSetsLoading || isCCPairsLoading) {
|
||||
if (
|
||||
isDocumentSetsLoading ||
|
||||
isCCPairsLoading ||
|
||||
isEditableDocumentSetsLoading
|
||||
) {
|
||||
return <ThreeDotsLoader />;
|
||||
}
|
||||
|
||||
@ -228,6 +316,10 @@ const Main = () => {
|
||||
return <div>Error: {documentSetsError}</div>;
|
||||
}
|
||||
|
||||
if (editableDocumentSetsError || !editableDocumentSets) {
|
||||
return <div>Error: {editableDocumentSetsError}</div>;
|
||||
}
|
||||
|
||||
if (ccPairsError || !ccPairs) {
|
||||
return <div>Error: {ccPairsError}</div>;
|
||||
}
|
||||
@ -258,8 +350,10 @@ const Main = () => {
|
||||
<Divider />
|
||||
<DocumentSetTable
|
||||
documentSets={documentSets}
|
||||
editableDocumentSets={editableDocumentSets}
|
||||
ccPairs={ccPairs}
|
||||
refresh={refreshDocumentSets}
|
||||
refreshEditable={refreshEditableDocumentSets}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
</>
|
||||
|
@ -18,11 +18,11 @@ import {
|
||||
} from "@/lib/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FiCheck,
|
||||
FiChevronDown,
|
||||
FiChevronRight,
|
||||
FiSettings,
|
||||
FiXCircle,
|
||||
FiLock,
|
||||
FiUnlock,
|
||||
} from "react-icons/fi";
|
||||
import { Tooltip } from "@/components/tooltip/Tooltip";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
@ -134,9 +134,11 @@ function SummaryRow({
|
||||
function ConnectorRow({
|
||||
ccPairsIndexingStatus,
|
||||
invisible,
|
||||
isEditable,
|
||||
}: {
|
||||
ccPairsIndexingStatus: ConnectorIndexingStatus<any, any>;
|
||||
invisible?: boolean;
|
||||
isEditable: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
@ -225,9 +227,9 @@ function ConnectorRow({
|
||||
className={`hover:bg-hover-light ${
|
||||
invisible ? "invisible h-0 !-mb-10" : "border border-border !border-b"
|
||||
} w-full cursor-pointer relative`}
|
||||
onClick={() =>
|
||||
router.push(`/admin/connector/${ccPairsIndexingStatus.cc_pair_id}`)
|
||||
}
|
||||
onClick={() => {
|
||||
router.push(`/admin/connector/${ccPairsIndexingStatus.cc_pair_id}`);
|
||||
}}
|
||||
>
|
||||
<TableCell className={`!pr-0 w-[${columnWidths.first}]`}>
|
||||
<p className="w-[200px] inline-block ellipsis truncate">
|
||||
@ -243,9 +245,17 @@ function ConnectorRow({
|
||||
{isPaidEnterpriseFeaturesEnabled && (
|
||||
<TableCell className={`w-[${columnWidths.fourth}]`}>
|
||||
{ccPairsIndexingStatus.public_doc ? (
|
||||
<FiCheck className="my-auto text-emerald-600" size="18" />
|
||||
<Badge
|
||||
size="md"
|
||||
color={isEditable ? "green" : "gray"}
|
||||
icon={FiUnlock}
|
||||
>
|
||||
Public
|
||||
</Badge>
|
||||
) : (
|
||||
<FiXCircle className="my-auto text-red-600" />
|
||||
<Badge size="md" color={isEditable ? "blue" : "gray"} icon={FiLock}>
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
@ -260,9 +270,14 @@ function ConnectorRow({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={`w-[${columnWidths.seventh}]`}>
|
||||
<CustomTooltip content="Manage Connector">
|
||||
<FiSettings className="cursor-pointer" onClick={handleManageClick} />
|
||||
</CustomTooltip>
|
||||
{isEditable && (
|
||||
<CustomTooltip content="Manage Connector">
|
||||
<FiSettings
|
||||
className="cursor-pointer"
|
||||
onClick={handleManageClick}
|
||||
/>
|
||||
</CustomTooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@ -270,8 +285,10 @@ function ConnectorRow({
|
||||
|
||||
export function CCPairIndexingStatusTable({
|
||||
ccPairsIndexingStatuses,
|
||||
editableCcPairsIndexingStatuses,
|
||||
}: {
|
||||
ccPairsIndexingStatuses: ConnectorIndexingStatus<any, any>[];
|
||||
editableCcPairsIndexingStatuses: ConnectorIndexingStatus<any, any>[];
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
@ -294,12 +311,29 @@ export function CCPairIndexingStatusTable({
|
||||
const { groupedStatuses, sortedSources, groupSummaries } = useMemo(() => {
|
||||
const grouped: Record<ValidSources, ConnectorIndexingStatus<any, any>[]> =
|
||||
{} as Record<ValidSources, ConnectorIndexingStatus<any, any>[]>;
|
||||
|
||||
// First, add editable connectors
|
||||
editableCcPairsIndexingStatuses.forEach((status) => {
|
||||
const source = status.connector.source;
|
||||
if (!grouped[source]) {
|
||||
grouped[source] = [];
|
||||
}
|
||||
grouped[source].unshift(status);
|
||||
});
|
||||
|
||||
// Then, add non-editable connectors
|
||||
ccPairsIndexingStatuses.forEach((status) => {
|
||||
const source = status.connector.source;
|
||||
if (!grouped[source]) {
|
||||
grouped[source] = [];
|
||||
}
|
||||
grouped[source].push(status);
|
||||
if (
|
||||
!editableCcPairsIndexingStatuses.some(
|
||||
(e) => e.cc_pair_id === status.cc_pair_id
|
||||
)
|
||||
) {
|
||||
grouped[source].push(status);
|
||||
}
|
||||
});
|
||||
|
||||
const sorted = Object.keys(grouped).sort() as ValidSources[];
|
||||
@ -329,7 +363,7 @@ export function CCPairIndexingStatusTable({
|
||||
sortedSources: sorted,
|
||||
groupSummaries: summaries,
|
||||
};
|
||||
}, [ccPairsIndexingStatuses]);
|
||||
}, [ccPairsIndexingStatuses, editableCcPairsIndexingStatuses]);
|
||||
|
||||
const toggleSource = (
|
||||
source: ValidSources,
|
||||
@ -410,6 +444,7 @@ export function CCPairIndexingStatusTable({
|
||||
deletion_attempt: null,
|
||||
is_deletable: true,
|
||||
}}
|
||||
isEditable={false}
|
||||
/>
|
||||
<div className="-mb-10" />
|
||||
|
||||
@ -474,7 +509,7 @@ export function CCPairIndexingStatusTable({
|
||||
<TableHeaderCell
|
||||
className={`w-[${columnWidths.fourth}]`}
|
||||
>
|
||||
Public
|
||||
Permissions
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell
|
||||
@ -498,6 +533,11 @@ export function CCPairIndexingStatusTable({
|
||||
<ConnectorRow
|
||||
key={ccPairsIndexingStatus.cc_pair_id}
|
||||
ccPairsIndexingStatus={ccPairsIndexingStatus}
|
||||
isEditable={editableCcPairsIndexingStatuses.some(
|
||||
(e) =>
|
||||
e.cc_pair_id ===
|
||||
ccPairsIndexingStatus.cc_pair_id
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
@ -21,15 +21,31 @@ function Main() {
|
||||
errorHandlingFetcher,
|
||||
{ refreshInterval: 10000 } // 10 seconds
|
||||
);
|
||||
const {
|
||||
data: editableIndexAttemptData,
|
||||
isLoading: editableIndexAttemptIsLoading,
|
||||
error: editableIndexAttemptError,
|
||||
} = useSWR<ConnectorIndexingStatus<any, any>[]>(
|
||||
"/api/manage/admin/connector/indexing-status?get_editable=true",
|
||||
errorHandlingFetcher,
|
||||
{ refreshInterval: 10000 } // 10 seconds
|
||||
);
|
||||
|
||||
if (indexAttemptIsLoading) {
|
||||
if (indexAttemptIsLoading || editableIndexAttemptIsLoading) {
|
||||
return <LoadingAnimation text="" />;
|
||||
}
|
||||
|
||||
if (indexAttemptError || !indexAttemptData) {
|
||||
if (
|
||||
indexAttemptError ||
|
||||
!indexAttemptData ||
|
||||
editableIndexAttemptError ||
|
||||
!editableIndexAttemptData
|
||||
) {
|
||||
return (
|
||||
<div className="text-error">
|
||||
{indexAttemptError?.info?.detail || "Error loading indexing history."}
|
||||
{indexAttemptError?.info?.detail ||
|
||||
editableIndexAttemptError?.info?.detail ||
|
||||
"Error loading indexing history."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -58,7 +74,10 @@ function Main() {
|
||||
});
|
||||
|
||||
return (
|
||||
<CCPairIndexingStatusTable ccPairsIndexingStatuses={indexAttemptData} />
|
||||
<CCPairIndexingStatusTable
|
||||
ccPairsIndexingStatuses={indexAttemptData}
|
||||
editableCcPairsIndexingStatuses={editableIndexAttemptData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Formik, Form, Field, ErrorMessage } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { ModalWrapper } from "@/app/chat/modal/ModalWrapper";
|
||||
import { ModalWrapper } from "@/components/modals/ModalWrapper";
|
||||
import { Button, Textarea, TextInput } from "@tremor/react";
|
||||
|
||||
import { BookstackIcon } from "@/components/icons/icons";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Formik, Form, Field, ErrorMessage } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { ModalWrapper } from "@/app/chat/modal/ModalWrapper";
|
||||
import { ModalWrapper } from "@/components/modals/ModalWrapper";
|
||||
import { Button, Textarea, TextInput } from "@tremor/react";
|
||||
import { useInputPrompt } from "../hooks";
|
||||
import { EditPromptModalProps } from "../interfaces";
|
||||
|
@ -24,6 +24,7 @@ type TokenRateLimitTableArgs = {
|
||||
description?: string;
|
||||
fetchUrl: string;
|
||||
hideHeading?: boolean;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export const TokenRateLimitTable = ({
|
||||
@ -32,6 +33,7 @@ export const TokenRateLimitTable = ({
|
||||
description,
|
||||
fetchUrl,
|
||||
hideHeading,
|
||||
isAdmin,
|
||||
}: TokenRateLimitTableArgs) => {
|
||||
const shouldRenderGroupName = () =>
|
||||
tokenRateLimits.length > 0 && tokenRateLimits[0].group_name !== undefined;
|
||||
@ -79,7 +81,9 @@ export const TokenRateLimitTable = ({
|
||||
{!hideHeading && description && (
|
||||
<Text className="my-2">{description}</Text>
|
||||
)}
|
||||
<Table className={`overflow-visible ${!hideHeading && "my-8"}`}>
|
||||
<Table
|
||||
className={`overflow-visible ${!hideHeading && "my-8"} [&_td]:text-center [&_th]:text-center`}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Enabled</TableHeaderCell>
|
||||
@ -88,7 +92,7 @@ export const TokenRateLimitTable = ({
|
||||
)}
|
||||
<TableHeaderCell>Time Window (Hours)</TableHeaderCell>
|
||||
<TableHeaderCell>Token Budget (Thousands)</TableHeaderCell>
|
||||
<TableHeaderCell>Delete</TableHeaderCell>
|
||||
{isAdmin && <TableHeaderCell>Delete</TableHeaderCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@ -96,15 +100,33 @@ export const TokenRateLimitTable = ({
|
||||
return (
|
||||
<TableRow key={tokenRateLimit.token_id}>
|
||||
<TableCell>
|
||||
<div
|
||||
onClick={() => handleEnabledChange(tokenRateLimit.token_id)}
|
||||
className="px-1 py-0.5 hover:bg-hover-light rounded flex cursor-pointer select-none w-24 flex"
|
||||
>
|
||||
<div className="mx-auto flex">
|
||||
<CustomCheckbox checked={tokenRateLimit.enabled} />
|
||||
<p className="ml-2">
|
||||
{tokenRateLimit.enabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
onClick={
|
||||
isAdmin
|
||||
? () => handleEnabledChange(tokenRateLimit.token_id)
|
||||
: undefined
|
||||
}
|
||||
className={`px-1 py-0.5 rounded select-none w-24 ${
|
||||
isAdmin
|
||||
? "hover:bg-hover-light cursor-pointer"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<CustomCheckbox
|
||||
checked={tokenRateLimit.enabled}
|
||||
onChange={
|
||||
isAdmin
|
||||
? () =>
|
||||
handleEnabledChange(tokenRateLimit.token_id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<p className="ml-2">
|
||||
{tokenRateLimit.enabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -113,13 +135,23 @@ export const TokenRateLimitTable = ({
|
||||
{tokenRateLimit.group_name}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{tokenRateLimit.period_hours}</TableCell>
|
||||
<TableCell>{tokenRateLimit.token_budget}</TableCell>
|
||||
<TableCell>
|
||||
<DeleteButton
|
||||
onClick={() => handleDelete(tokenRateLimit.token_id)}
|
||||
/>
|
||||
{tokenRateLimit.period_hours +
|
||||
" hour" +
|
||||
(tokenRateLimit.period_hours > 1 ? "s" : "")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tokenRateLimit.token_budget + " thousand tokens"}
|
||||
</TableCell>
|
||||
{isAdmin && (
|
||||
<TableCell>
|
||||
<div className="flex justify-center">
|
||||
<DeleteButton
|
||||
onClick={() => handleDelete(tokenRateLimit.token_id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
@ -135,12 +167,14 @@ export const GenericTokenRateLimitTable = ({
|
||||
description,
|
||||
hideHeading,
|
||||
responseMapper,
|
||||
isAdmin = true,
|
||||
}: {
|
||||
fetchUrl: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
hideHeading?: boolean;
|
||||
responseMapper?: (data: any) => TokenRateLimitDisplay[];
|
||||
isAdmin?: boolean;
|
||||
}) => {
|
||||
const { data, isLoading, error } = useSWR(fetchUrl, errorHandlingFetcher);
|
||||
|
||||
@ -164,6 +198,7 @@ export const GenericTokenRateLimitTable = ({
|
||||
title={title}
|
||||
description={description}
|
||||
hideHeading={hideHeading}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { FiTrash, FiX } from "react-icons/fi";
|
||||
import { ModalWrapper } from "./ModalWrapper";
|
||||
import { ModalWrapper } from "@/components/modals/ModalWrapper";
|
||||
import { BasicClickable } from "@/components/BasicClickable";
|
||||
|
||||
export const DeleteChatModal = ({
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { FeedbackType } from "../types";
|
||||
import { FiThumbsDown, FiThumbsUp } from "react-icons/fi";
|
||||
import { ModalWrapper } from "./ModalWrapper";
|
||||
import { ModalWrapper } from "@/components/modals/ModalWrapper";
|
||||
import {
|
||||
DislikeFeedbackIcon,
|
||||
FilledLikeIcon,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useRef } from "react";
|
||||
import { ModalWrapper } from "./ModalWrapper";
|
||||
import { Dispatch, SetStateAction, useState, useEffect, useRef } from "react";
|
||||
import { ModalWrapper } from "@/components/modals/ModalWrapper";
|
||||
import { Badge, Text } from "@tremor/react";
|
||||
import { getDisplayNameForModel, LlmOverride } from "@/lib/hooks";
|
||||
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ModalWrapper } from "./ModalWrapper";
|
||||
import { ModalWrapper } from "@/components/modals/ModalWrapper";
|
||||
import { Button, Callout, Divider, Text } from "@tremor/react";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { ChatSessionSharedStatus } from "../interfaces";
|
||||
|
@ -8,7 +8,6 @@ import {
|
||||
getChatRetentionInfo,
|
||||
renameChatSession,
|
||||
} from "../lib";
|
||||
import { DeleteChatModal } from "../modal/DeleteChatModal";
|
||||
import { BasicSelectable } from "@/components/BasicClickable";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
|
@ -47,13 +47,15 @@ export const DanswerApiKeyForm = ({
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: apiKey?.api_key_name || "",
|
||||
is_admin: apiKey?.api_key_role == "admin" ?? false,
|
||||
is_admin: apiKey?.api_key_role === "admin",
|
||||
}}
|
||||
onSubmit={async (values, formikHelpers) => {
|
||||
formikHelpers.setSubmitting(true);
|
||||
|
||||
// Map the boolean to a UserRole string
|
||||
const role: UserRole = values.is_admin ? "admin" : "basic";
|
||||
const role: UserRole = values.is_admin
|
||||
? UserRole.ADMIN
|
||||
: UserRole.BASIC;
|
||||
|
||||
// Prepare the payload with the UserRole
|
||||
const payload = {
|
||||
|
@ -1,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
import { AddMemberForm } from "./AddMemberForm";
|
||||
import { updateUserGroup } from "./lib";
|
||||
import { updateUserGroup, updateCuratorStatus } from "./lib";
|
||||
import { LoadingAnimation } from "@/components/Loading";
|
||||
import { ConnectorIndexingStatus, User, UserGroup } from "@/lib/types";
|
||||
import {
|
||||
ConnectorIndexingStatus,
|
||||
User,
|
||||
UserGroup,
|
||||
UserRole,
|
||||
} from "@/lib/types";
|
||||
import { AddConnectorForm } from "./AddConnectorForm";
|
||||
import {
|
||||
Table,
|
||||
@ -18,12 +23,15 @@ import {
|
||||
Divider,
|
||||
Button,
|
||||
Text,
|
||||
Select,
|
||||
SelectItem,
|
||||
} from "@tremor/react";
|
||||
import { DeleteButton } from "@/components/DeleteButton";
|
||||
import { Bubble } from "@/components/Bubble";
|
||||
import { BookmarkIcon, RobotIcon } from "@/components/icons/icons";
|
||||
import { AddTokenRateLimitForm } from "./AddTokenRateLimitForm";
|
||||
import { GenericTokenRateLimitTable } from "@/app/admin/token-rate-limits/TokenRateLimitTables";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
|
||||
interface GroupDisplayProps {
|
||||
users: User[];
|
||||
@ -32,6 +40,76 @@ interface GroupDisplayProps {
|
||||
refreshUserGroup: () => void;
|
||||
}
|
||||
|
||||
const UserRoleDropdown = ({
|
||||
user,
|
||||
group,
|
||||
onSuccess,
|
||||
onError,
|
||||
isAdmin,
|
||||
}: {
|
||||
user: User;
|
||||
group: UserGroup;
|
||||
onSuccess: () => void;
|
||||
onError: (message: string) => void;
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
const [localRole, setLocalRole] = useState(() => {
|
||||
if (user.role === UserRole.CURATOR) {
|
||||
return group.curator_ids.includes(user.id)
|
||||
? UserRole.CURATOR
|
||||
: UserRole.BASIC;
|
||||
}
|
||||
return user.role;
|
||||
});
|
||||
const [isSettingRole, setIsSettingRole] = useState(false);
|
||||
|
||||
const handleChange = async (value: string) => {
|
||||
if (value === localRole) return;
|
||||
if (value === UserRole.BASIC || value === UserRole.CURATOR) {
|
||||
setIsSettingRole(true);
|
||||
setLocalRole(value);
|
||||
try {
|
||||
const response = await updateCuratorStatus(group.id, {
|
||||
user_id: user.id,
|
||||
is_curator: value === UserRole.CURATOR,
|
||||
});
|
||||
if (response.ok) {
|
||||
onSuccess();
|
||||
user.role = value;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to update user role");
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError(error.message);
|
||||
setLocalRole(user.role);
|
||||
} finally {
|
||||
setIsSettingRole(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isEditable =
|
||||
(user.role === UserRole.BASIC || user.role === UserRole.CURATOR) && isAdmin;
|
||||
|
||||
if (isEditable) {
|
||||
return (
|
||||
<div className="w-40 ">
|
||||
<Select
|
||||
value={localRole}
|
||||
onValueChange={handleChange}
|
||||
disabled={isSettingRole}
|
||||
>
|
||||
<SelectItem value={UserRole.BASIC}>Basic</SelectItem>
|
||||
<SelectItem value={UserRole.CURATOR}>Curator</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <div>{user.role}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export const GroupDisplay = ({
|
||||
users,
|
||||
ccPairs,
|
||||
@ -42,6 +120,31 @@ export const GroupDisplay = ({
|
||||
const [addMemberFormVisible, setAddMemberFormVisible] = useState(false);
|
||||
const [addConnectorFormVisible, setAddConnectorFormVisible] = useState(false);
|
||||
const [addRateLimitFormVisible, setAddRateLimitFormVisible] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
const handlePopup = (message: string, type: "success" | "error") => {
|
||||
setPopup({ message, type });
|
||||
};
|
||||
|
||||
const onRoleChangeSuccess = () =>
|
||||
handlePopup("User role updated successfully!", "success");
|
||||
const onRoleChangeError = (errorMsg: string) =>
|
||||
handlePopup(`Unable to update user role - ${errorMsg}`, "error");
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -71,9 +174,12 @@ export const GroupDisplay = ({
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Email</TableHeaderCell>
|
||||
<TableHeaderCell className="flex w-full">
|
||||
<div className="ml-auto">Remove User</div>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
{isAdmin && (
|
||||
<TableHeaderCell className="flex w-full">
|
||||
<div className="ml-auto">Remove User</div>
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@ -83,43 +189,57 @@ export const GroupDisplay = ({
|
||||
<TableCell className="whitespace-normal break-all">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UserRoleDropdown
|
||||
user={user}
|
||||
group={userGroup}
|
||||
onSuccess={onRoleChangeSuccess}
|
||||
onError={onRoleChangeError}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex w-full">
|
||||
<div className="ml-auto m-2">
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
const response = await updateUserGroup(
|
||||
userGroup.id,
|
||||
{
|
||||
user_ids: userGroup.users
|
||||
.filter(
|
||||
(userGroupUser) =>
|
||||
userGroupUser.id !== user.id
|
||||
)
|
||||
.map((userGroupUser) => userGroupUser.id),
|
||||
cc_pair_ids: userGroup.cc_pairs.map(
|
||||
(ccPair) => ccPair.id
|
||||
),
|
||||
{isAdmin && (
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
const response = await updateUserGroup(
|
||||
userGroup.id,
|
||||
{
|
||||
user_ids: userGroup.users
|
||||
.filter(
|
||||
(userGroupUser) =>
|
||||
userGroupUser.id !== user.id
|
||||
)
|
||||
.map(
|
||||
(userGroupUser) => userGroupUser.id
|
||||
),
|
||||
cc_pair_ids: userGroup.cc_pairs.map(
|
||||
(ccPair) => ccPair.id
|
||||
),
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message:
|
||||
"Successfully removed user from group",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const responseJson = await response.json();
|
||||
const errorMsg =
|
||||
responseJson.detail ||
|
||||
responseJson.message;
|
||||
setPopup({
|
||||
message: `Error removing user from group - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message:
|
||||
"Successfully removed user from group",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const responseJson = await response.json();
|
||||
const errorMsg =
|
||||
responseJson.detail || responseJson.message;
|
||||
setPopup({
|
||||
message: `Error removing user from group - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
refreshUserGroup();
|
||||
}}
|
||||
/>
|
||||
refreshUserGroup();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -134,15 +254,17 @@ export const GroupDisplay = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="xs"
|
||||
color="green"
|
||||
onClick={() => setAddMemberFormVisible(true)}
|
||||
disabled={!userGroup.is_up_to_date}
|
||||
>
|
||||
Add Users
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="xs"
|
||||
color="green"
|
||||
onClick={() => setAddMemberFormVisible(true)}
|
||||
disabled={!userGroup.is_up_to_date}
|
||||
>
|
||||
Add Users
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{addMemberFormVisible && (
|
||||
<AddMemberForm
|
||||
@ -233,15 +355,17 @@ export const GroupDisplay = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={() => setAddConnectorFormVisible(true)}
|
||||
size="xs"
|
||||
color="green"
|
||||
disabled={!userGroup.is_up_to_date}
|
||||
>
|
||||
Add Connectors
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={() => setAddConnectorFormVisible(true)}
|
||||
size="xs"
|
||||
color="green"
|
||||
disabled={!userGroup.is_up_to_date}
|
||||
>
|
||||
Add Connectors
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{addConnectorFormVisible && (
|
||||
<AddConnectorForm
|
||||
@ -319,16 +443,19 @@ export const GroupDisplay = ({
|
||||
<GenericTokenRateLimitTable
|
||||
fetchUrl={`/api/admin/token-rate-limits/user-group/${userGroup.id}`}
|
||||
hideHeading
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
<Button
|
||||
color="green"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
onClick={() => setAddRateLimitFormVisible(true)}
|
||||
>
|
||||
Create a Token Rate Limit
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
color="green"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
onClick={() => setAddRateLimitFormVisible(true)}
|
||||
>
|
||||
Create a Token Rate Limit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { UserGroupUpdate } from "../types";
|
||||
import { UserGroupUpdate, SetCuratorRequest } from "../types";
|
||||
|
||||
export const updateUserGroup = async (
|
||||
groupId: number,
|
||||
@ -13,3 +13,17 @@ export const updateUserGroup = async (
|
||||
body: JSON.stringify(userGroup),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateCuratorStatus = async (
|
||||
groupId: number,
|
||||
curatorRequest: SetCuratorRequest
|
||||
) => {
|
||||
const url = `/api/manage/admin/user-group/${groupId}/set-curator`;
|
||||
return await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(curatorRequest),
|
||||
});
|
||||
};
|
||||
|
@ -4,7 +4,9 @@ import { GroupsIcon } from "@/components/icons/icons";
|
||||
import { UserGroupsTable } from "./UserGroupsTable";
|
||||
import { UserGroupCreationForm } from "./UserGroupCreationForm";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
import { User, UserRole } from "@/lib/types";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import {
|
||||
useConnectorCredentialIndexingStatus,
|
||||
@ -32,6 +34,24 @@ const Main = () => {
|
||||
error: usersError,
|
||||
} = useUsers();
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
if (isLoading || isCCPairsLoading || userIsLoading) {
|
||||
return <ThreeDotsLoader />;
|
||||
}
|
||||
@ -51,14 +71,16 @@ const Main = () => {
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
<div className="my-3">
|
||||
<Button size="xs" color="green" onClick={() => setShowForm(true)}>
|
||||
Create New User Group
|
||||
</Button>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="my-3">
|
||||
<Button size="xs" color="green" onClick={() => setShowForm(true)}>
|
||||
Create New User Group
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data.length > 0 && (
|
||||
<div>
|
||||
<Divider />
|
||||
{isAdmin && <Divider />}
|
||||
<UserGroupsTable
|
||||
userGroups={data}
|
||||
setPopup={setPopup}
|
||||
|
@ -3,6 +3,11 @@ export interface UserGroupUpdate {
|
||||
cc_pair_ids: number[];
|
||||
}
|
||||
|
||||
export interface SetCuratorRequest {
|
||||
user_id: string;
|
||||
is_curator: boolean;
|
||||
}
|
||||
|
||||
export interface UserGroupCreation {
|
||||
name: string;
|
||||
user_ids: string[];
|
||||
|
155
web/src/components/IsPublicGroupSelector.tsx
Normal file
155
web/src/components/IsPublicGroupSelector.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { FormikProps, FieldArray, ArrayHelpers, ErrorMessage } from "formik";
|
||||
import { Text, Divider } from "@tremor/react";
|
||||
import { FiUsers } from "react-icons/fi";
|
||||
import { UserGroup, User, UserRole } from "@/lib/types";
|
||||
import { useUserGroups } from "@/lib/hooks";
|
||||
import { BooleanFormField } from "@/components/admin/connectors/Field";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
|
||||
export type IsPublicGroupSelectorFormType = {
|
||||
is_public: boolean;
|
||||
groups: number[];
|
||||
};
|
||||
|
||||
export const IsPublicGroupSelector = <T extends IsPublicGroupSelectorFormType>({
|
||||
formikProps,
|
||||
objectName,
|
||||
enforceGroupSelection = true,
|
||||
}: {
|
||||
formikProps: FormikProps<T>;
|
||||
objectName: string;
|
||||
enforceGroupSelection?: boolean;
|
||||
}) => {
|
||||
const { data: userGroups, isLoading: userGroupsIsLoading } = useUserGroups();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
formikProps.setFieldValue("is_public", user.role === UserRole.ADMIN);
|
||||
|
||||
// Select the only group by default if there's only one
|
||||
if (
|
||||
userGroups &&
|
||||
userGroups.length === 1 &&
|
||||
formikProps.values.groups.length === 0 &&
|
||||
user.role !== UserRole.ADMIN
|
||||
) {
|
||||
formikProps.setFieldValue("groups", [userGroups[0].id]);
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, [userGroups]); // Add userGroups as a dependency
|
||||
|
||||
if (isLoading || userGroupsIsLoading) {
|
||||
return null; // or return a loading spinner if preferred
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Divider />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<BooleanFormField
|
||||
name="is_public"
|
||||
label="Is Public?"
|
||||
disabled={!isAdmin}
|
||||
subtext={
|
||||
<span className="block mt-2 text-sm text-gray-500">
|
||||
If set, then {objectName}s indexed by this {objectName} will be
|
||||
visible to <b>all users</b>. If turned off, then only users who
|
||||
explicitly have been given access to the {objectName}s (e.g.
|
||||
through a User Group) will have access.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!formikProps.values.is_public || !isAdmin) && (
|
||||
<>
|
||||
<div className="flex gap-x-2 items-center">
|
||||
<div className="block font-medium text-base">
|
||||
Assign group access for this {objectName}
|
||||
</div>
|
||||
</div>
|
||||
<Text className="mb-3">
|
||||
{isAdmin || !enforceGroupSelection ? (
|
||||
<>
|
||||
This {objectName} will be visible/accessible by the groups
|
||||
selected below
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Curators must select one or more groups to give access to this{" "}
|
||||
{objectName}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<FieldArray
|
||||
name="groups"
|
||||
render={(arrayHelpers: ArrayHelpers) => (
|
||||
<div className="flex gap-2 flex-wrap mb-4">
|
||||
{userGroupsIsLoading ? (
|
||||
<div className="animate-pulse bg-gray-200 h-8 w-32 rounded"></div>
|
||||
) : (
|
||||
userGroups &&
|
||||
userGroups.map((userGroup: UserGroup) => {
|
||||
const ind = formikProps.values.groups.indexOf(userGroup.id);
|
||||
let isSelected = ind !== -1;
|
||||
return (
|
||||
<div
|
||||
key={userGroup.id}
|
||||
className={`
|
||||
px-3
|
||||
py-1
|
||||
rounded-lg
|
||||
border
|
||||
border-border
|
||||
w-fit
|
||||
flex
|
||||
cursor-pointer
|
||||
${isSelected ? "bg-background-strong" : "hover:bg-hover"}
|
||||
`}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
arrayHelpers.remove(ind);
|
||||
} else {
|
||||
arrayHelpers.push(userGroup.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="my-auto flex">
|
||||
<FiUsers className="my-auto mr-2" /> {userGroup.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<ErrorMessage
|
||||
name="groups"
|
||||
component="div"
|
||||
className="text-error text-sm mt-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -4,7 +4,7 @@ import { useState, useRef, useContext } from "react";
|
||||
import { FiLogOut } from "react-icons/fi";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { User } from "@/lib/types";
|
||||
import { User, UserRole } from "@/lib/types";
|
||||
import { checkUserIsNoAuthUser, logout } from "@/lib/user";
|
||||
import { Popover } from "./popover/Popover";
|
||||
import { LOGOUT_DISABLED } from "@/lib/constants";
|
||||
@ -42,7 +42,10 @@ export function UserDropdown({
|
||||
});
|
||||
};
|
||||
|
||||
const showAdminPanel = !user || user.role === "admin";
|
||||
const showAdminPanel = !user || user.role === UserRole.ADMIN;
|
||||
const showCuratorPanel =
|
||||
user &&
|
||||
(user.role === UserRole.CURATOR || user.role === UserRole.GLOBAL_CURATOR);
|
||||
const showLogout =
|
||||
user && !checkUserIsNoAuthUser(user.id) && !LOGOUT_DISABLED;
|
||||
|
||||
@ -109,6 +112,18 @@ export function UserDropdown({
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{showCuratorPanel && (
|
||||
<>
|
||||
<Link
|
||||
href="/admin/indexing/status"
|
||||
className="flex py-3 px-4 cursor-pointer !
|
||||
rounded hover:bg-hover-light"
|
||||
>
|
||||
<LightSettingsIcon className="h-5 w-5 my-auto mr-2" />
|
||||
Curator Panel
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showLogout && (
|
||||
<>
|
||||
|
@ -22,7 +22,7 @@ import {
|
||||
ClosedBookIcon,
|
||||
SearchIcon,
|
||||
} from "@/components/icons/icons";
|
||||
|
||||
import { UserRole } from "@/lib/types";
|
||||
import { FiActivity, FiBarChart2 } from "react-icons/fi";
|
||||
import { UserDropdown } from "../UserDropdown";
|
||||
import { User } from "@/lib/types";
|
||||
@ -40,6 +40,8 @@ export function ClientLayout({
|
||||
children: React.ReactNode;
|
||||
enableEnterprise: boolean;
|
||||
}) {
|
||||
const isCurator =
|
||||
user?.role === UserRole.CURATOR || user?.role === UserRole.GLOBAL_CURATOR;
|
||||
const pathname = usePathname();
|
||||
const settings = useContext(SettingsContext);
|
||||
|
||||
@ -123,86 +125,53 @@ export function ClientLayout({
|
||||
),
|
||||
link: "/admin/assistants",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
{/* <FiSlack size={18} /> */}
|
||||
<SlackIconSkeleton />
|
||||
<div className="ml-1">Slack Bots</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/bot",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
{/* <FiTool size={18} className="my-auto" /> */}
|
||||
<ToolIconSkeleton size={18} />
|
||||
<div className="ml-1">Tools</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/tools",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ClipboardIcon size={18} />
|
||||
<div className="ml-1">Standard Answers</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/standard-answer",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ClosedBookIcon size={18} />
|
||||
<div className="ml-1">Prompt Library</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/prompt-library",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Configuration",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<CpuIconSkeleton size={18} />
|
||||
<div className="ml-1">LLM</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/configuration/llm",
|
||||
},
|
||||
{
|
||||
error: settings?.settings.needs_reindexing,
|
||||
name: (
|
||||
<div className="flex">
|
||||
<SearchIcon />
|
||||
<CustomTooltip content="Navigate here to update your search settings">
|
||||
<div className="ml-1">Search Settings</div>
|
||||
</CustomTooltip>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/configuration/search",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "User Management",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<UsersIconSkeleton size={18} />
|
||||
<div className="ml-1">Users</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/users",
|
||||
},
|
||||
...(enableEnterprise
|
||||
...(!isCurator
|
||||
? [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<SlackIconSkeleton />
|
||||
<div className="ml-1">Slack Bots</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/bot",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ToolIconSkeleton size={18} />
|
||||
<div className="ml-1">Tools</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/tools",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ClipboardIcon size={18} />
|
||||
<div className="ml-1">Standard Answers</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/standard-answer",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ClosedBookIcon size={18} />
|
||||
<div className="ml-1">Prompt Library</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/prompt-library",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
...(isCurator
|
||||
? [
|
||||
{
|
||||
name: "User Management",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
@ -212,91 +181,148 @@ export function ClientLayout({
|
||||
),
|
||||
link: "/admin/groups",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<KeyIconSkeleton size={18} />
|
||||
<div className="ml-1">API Keys</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/api-key",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ShieldIconSkeleton size={18} />
|
||||
<div className="ml-1">Token Rate Limits</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/token-rate-limits",
|
||||
},
|
||||
],
|
||||
},
|
||||
...(enableEnterprise
|
||||
? [
|
||||
{
|
||||
name: "Performance",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<FiActivity size={18} />
|
||||
<div className="ml-1">Usage Statistics</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/usage",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<DatabaseIconSkeleton size={18} />
|
||||
<div className="ml-1">Query History</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/query-history",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<FiBarChart2 size={18} />
|
||||
<div className="ml-1">Custom Analytics</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/custom-analytics",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Settings",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<SettingsIconSkeleton size={18} />
|
||||
<div className="ml-1">Workspace Settings</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/settings",
|
||||
},
|
||||
...(enableEnterprise
|
||||
? [
|
||||
...(!isCurator
|
||||
? [
|
||||
{
|
||||
name: "Configuration",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<PaintingIconSkeleton size={18} />
|
||||
<div className="ml-1">Whitelabeling</div>
|
||||
<CpuIconSkeleton size={18} />
|
||||
<div className="ml-1">LLM</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/whitelabeling",
|
||||
link: "/admin/configuration/llm",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
error: settings?.settings.needs_reindexing,
|
||||
name: (
|
||||
<div className="flex">
|
||||
<SearchIcon />
|
||||
<CustomTooltip content="Navigate here to update your search settings">
|
||||
<div className="ml-1">Search Settings</div>
|
||||
</CustomTooltip>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/configuration/search",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "User Management",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<UsersIconSkeleton size={18} />
|
||||
<div className="ml-1">Users</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/users",
|
||||
},
|
||||
...(enableEnterprise
|
||||
? [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<GroupsIconSkeleton size={18} />
|
||||
<div className="ml-1">Groups</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/groups",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<KeyIconSkeleton size={18} />
|
||||
<div className="ml-1">API Keys</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/api-key",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<ShieldIconSkeleton size={18} />
|
||||
<div className="ml-1">Token Rate Limits</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/token-rate-limits",
|
||||
},
|
||||
],
|
||||
},
|
||||
...(enableEnterprise
|
||||
? [
|
||||
{
|
||||
name: "Performance",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<FiActivity size={18} />
|
||||
<div className="ml-1">Usage Statistics</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/usage",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<DatabaseIconSkeleton size={18} />
|
||||
<div className="ml-1">Query History</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/query-history",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<FiBarChart2 size={18} />
|
||||
<div className="ml-1">Custom Analytics</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/custom-analytics",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Settings",
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<SettingsIconSkeleton size={18} />
|
||||
<div className="ml-1">Workspace Settings</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/settings",
|
||||
},
|
||||
...(enableEnterprise
|
||||
? [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<PaintingIconSkeleton size={18} />
|
||||
<div className="ml-1">Whitelabeling</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/whitelabeling",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { User } from "@/lib/types";
|
||||
import { User, UserRole } from "@/lib/types";
|
||||
import {
|
||||
AuthTypeMetadata,
|
||||
getAuthTypeMetadataSS,
|
||||
@ -32,7 +32,7 @@ export async function Layout({ children }: { children: React.ReactNode }) {
|
||||
if (!user) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
if (user.role !== "admin") {
|
||||
if (user.role === UserRole.BASIC) {
|
||||
return redirect("/");
|
||||
}
|
||||
if (!user.is_verified && requiresVerification) {
|
||||
|
@ -64,6 +64,8 @@ export function CredentialForm<T extends Yup.AnyObject>({
|
||||
submitCredential<T>({
|
||||
credential_json: values,
|
||||
admin_public: true,
|
||||
curator_public: false,
|
||||
groups: [],
|
||||
source: source,
|
||||
}).then(({ message, isSuccess }) => {
|
||||
setPopup({ message, type: isSuccess ? "success" : "error" });
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { type User, UserStatus } from "@/lib/types";
|
||||
import { type User, UserStatus, UserRole } from "@/lib/types";
|
||||
import CenteredPageSelector from "./CenteredPageSelector";
|
||||
import { type PageSelectorProps } from "@/components/PageSelector";
|
||||
import { HidableSection } from "@/app/admin/assistants/HidableSection";
|
||||
@ -13,7 +13,19 @@ import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
Button,
|
||||
Select,
|
||||
SelectItem,
|
||||
} from "@tremor/react";
|
||||
import { GenericConfirmModal } from "@/components/modals/GenericConfirmModal";
|
||||
import { useState } from "react";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
|
||||
const USER_ROLE_LABELS: Record<UserRole, string> = {
|
||||
[UserRole.BASIC]: "Basic",
|
||||
[UserRole.ADMIN]: "Admin",
|
||||
[UserRole.GLOBAL_CURATOR]: "Global Curator",
|
||||
[UserRole.CURATOR]: "Curator",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
users: Array<User>;
|
||||
@ -21,33 +33,88 @@ interface Props {
|
||||
mutate: () => void;
|
||||
}
|
||||
|
||||
const PromoterButton = ({
|
||||
const UserRoleDropdown = ({
|
||||
user,
|
||||
promote,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
user: User;
|
||||
promote: boolean;
|
||||
onSuccess: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) => {
|
||||
const { trigger, isMutating } = useSWRMutation(
|
||||
promote
|
||||
? "/api/manage/promote-user-to-admin"
|
||||
: "/api/manage/demote-admin-to-basic",
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [pendingRole, setPendingRole] = useState<string | null>(null);
|
||||
|
||||
const { trigger: setUserRole, isMutating: isSettingRole } = useSWRMutation(
|
||||
"/api/manage/set-user-role",
|
||||
userMutationFetcher,
|
||||
{ onSuccess, onError }
|
||||
);
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (value === user.role) return;
|
||||
if (user.role === UserRole.CURATOR) {
|
||||
setShowConfirmModal(true);
|
||||
setPendingRole(value);
|
||||
} else {
|
||||
setUserRole({
|
||||
user_email: user.email,
|
||||
new_role: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (pendingRole) {
|
||||
setUserRole({
|
||||
user_email: user.email,
|
||||
new_role: pendingRole,
|
||||
});
|
||||
}
|
||||
setShowConfirmModal(false);
|
||||
setPendingRole(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="w-min"
|
||||
onClick={() => trigger({ user_email: user.email })}
|
||||
disabled={isMutating}
|
||||
size="xs"
|
||||
>
|
||||
{promote ? "Promote" : "Demote"} to {promote ? "Admin" : "Basic"} User
|
||||
</Button>
|
||||
<>
|
||||
<Select
|
||||
value={user.role}
|
||||
onValueChange={handleChange}
|
||||
disabled={isSettingRole}
|
||||
className="w-40 mx-auto"
|
||||
>
|
||||
{Object.entries(USER_ROLE_LABELS).map(([role, label]) =>
|
||||
!isPaidEnterpriseFeaturesEnabled &&
|
||||
(role === UserRole.CURATOR ||
|
||||
role === UserRole.GLOBAL_CURATOR) ? null : (
|
||||
<SelectItem
|
||||
key={role}
|
||||
value={role}
|
||||
className={
|
||||
role === UserRole.CURATOR ? "opacity-30 cursor-not-allowed" : ""
|
||||
}
|
||||
title={
|
||||
role === UserRole.CURATOR
|
||||
? "Curator role must be assigned in the Groups tab"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
{showConfirmModal && (
|
||||
<GenericConfirmModal
|
||||
title="Change Curator Role"
|
||||
message={`Warning: Switching roles from Curator to ${USER_ROLE_LABELS[pendingRole as UserRole] ?? USER_ROLE_LABELS[user.role]} will remove their status as individual curators from all groups.`}
|
||||
confirmText={`Switch Role to ${USER_ROLE_LABELS[pendingRole as UserRole] ?? USER_ROLE_LABELS[user.role]}`}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -100,32 +167,16 @@ const SignedUpUserTable = ({
|
||||
}: Props & PageSelectorProps) => {
|
||||
if (!users.length) return null;
|
||||
|
||||
const onSuccess = (message: string) => {
|
||||
mutate();
|
||||
setPopup({
|
||||
message,
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
const onError = (message: string) => {
|
||||
setPopup({
|
||||
message,
|
||||
type: "error",
|
||||
});
|
||||
};
|
||||
const onPromotionSuccess = () => {
|
||||
onSuccess("User promoted to admin user!");
|
||||
};
|
||||
const onPromotionError = (errorMsg: string) => {
|
||||
onError(`Unable to promote user - ${errorMsg}`);
|
||||
};
|
||||
const onDemotionSuccess = () => {
|
||||
onSuccess("Admin demoted to basic user!");
|
||||
};
|
||||
const onDemotionError = (errorMsg: string) => {
|
||||
onError(`Unable to demote admin - ${errorMsg}`);
|
||||
const handlePopup = (message: string, type: "success" | "error") => {
|
||||
if (type === "success") mutate();
|
||||
setPopup({ message, type });
|
||||
};
|
||||
|
||||
const onRoleChangeSuccess = () =>
|
||||
handlePopup("User role updated successfully!", "success");
|
||||
const onRoleChangeError = (errorMsg: string) =>
|
||||
handlePopup(`Unable to update user role - ${errorMsg}`, "error");
|
||||
|
||||
return (
|
||||
<HidableSection sectionTitle="Current Users">
|
||||
<>
|
||||
@ -140,8 +191,8 @@ const SignedUpUserTable = ({
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Email</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell className="text-center">Role</TableHeaderCell>
|
||||
<TableHeaderCell className="text-center">Status</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<div className="flex">
|
||||
<div className="ml-auto">Actions</div>
|
||||
@ -154,19 +205,17 @@ const SignedUpUserTable = ({
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<i>{user.role === "admin" ? "Admin" : "User"}</i>
|
||||
<UserRoleDropdown
|
||||
user={user}
|
||||
onSuccess={onRoleChangeSuccess}
|
||||
onError={onRoleChangeError}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-center">
|
||||
<i>{user.status === "live" ? "Active" : "Inactive"}</i>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-end gap-y-2">
|
||||
<PromoterButton
|
||||
user={user}
|
||||
promote={user.role !== "admin"}
|
||||
onSuccess={onPromotionSuccess}
|
||||
onError={onPromotionError}
|
||||
/>
|
||||
<DeactivaterButton
|
||||
user={user}
|
||||
deactivate={user.status === UserStatus.live}
|
||||
|
@ -6,6 +6,7 @@ import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { FaSwatchbook } from "react-icons/fa";
|
||||
import { NewChatIcon } from "@/components/icons/icons";
|
||||
import { useState } from "react";
|
||||
import { useUserGroups } from "@/lib/hooks";
|
||||
import {
|
||||
deleteCredential,
|
||||
swapCredential,
|
||||
@ -27,6 +28,7 @@ import {
|
||||
ConfluenceCredentialJson,
|
||||
Credential,
|
||||
} from "@/lib/connectors/credentials";
|
||||
import { UserGroup } from "@/lib/types"; // Added this import
|
||||
|
||||
export default function CredentialSection({
|
||||
ccPair,
|
||||
@ -47,6 +49,11 @@ export default function CredentialSection({
|
||||
errorHandlingFetcher,
|
||||
{ refreshInterval: 5000 } // 5 seconds
|
||||
);
|
||||
const { data: editableCredentials } = useSWR<Credential<any>[]>(
|
||||
buildSimilarCredentialInfoURL(sourceType, true),
|
||||
errorHandlingFetcher,
|
||||
{ refreshInterval: 5000 }
|
||||
);
|
||||
|
||||
const onSwap = async (
|
||||
selectedCredential: Credential<any>,
|
||||
@ -112,7 +119,7 @@ export default function CredentialSection({
|
||||
};
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
if (!credentials) {
|
||||
if (!credentials || !editableCredentials) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@ -152,6 +159,7 @@ export default function CredentialSection({
|
||||
attachedConnector={ccPair.connector}
|
||||
defaultedCredential={defaultedCredential}
|
||||
credentials={credentials}
|
||||
editableCredentials={editableCredentials}
|
||||
onDeleteCredential={onDeleteCredential}
|
||||
onEditCredential={(credential: Credential<any>) =>
|
||||
onEditCredential(credential)
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button, Card } from "@tremor/react";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import { FaAccusoft } from "react-icons/fa";
|
||||
import { submitCredential } from "@/components/admin/connectors/CredentialForm";
|
||||
import { TextFormField } from "@/components/admin/connectors/Field";
|
||||
import { Form, Formik, FormikHelpers } from "formik";
|
||||
import { Form, Formik, FormikHelpers, FormikProps } from "formik";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { getSourceDocLink } from "@/lib/sources";
|
||||
import GDriveMain from "@/app/admin/connectors/[connector]/pages/gdrive/GoogleDrivePage";
|
||||
@ -14,10 +14,49 @@ import {
|
||||
credentialTemplates,
|
||||
getDisplayNameForCredentialKey,
|
||||
} from "@/lib/connectors/credentials";
|
||||
import { getCurrentUser } from "@/lib/user";
|
||||
import { User, UserRole } from "@/lib/types";
|
||||
import { PlusCircleIcon } from "../../icons/icons";
|
||||
import { GmailMain } from "@/app/admin/connectors/[connector]/pages/gmail/GmailPage";
|
||||
import { ActionType, dictionaryType, formType } from "../types";
|
||||
import { ActionType, dictionaryType } from "../types";
|
||||
import { createValidationSchema } from "../lib";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
|
||||
import {
|
||||
IsPublicGroupSelectorFormType,
|
||||
IsPublicGroupSelector,
|
||||
} from "@/components/IsPublicGroupSelector";
|
||||
|
||||
const CreateButton = ({
|
||||
onClick,
|
||||
isSubmitting,
|
||||
isAdmin,
|
||||
groups,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isSubmitting: boolean;
|
||||
isAdmin: boolean;
|
||||
groups: number[];
|
||||
}) => (
|
||||
<div className="flex justify-end w-full">
|
||||
<Button
|
||||
className="enabled:cursor-pointer disabled:cursor-not-allowed disabled:bg-blue-200 bg-blue-400 flex gap-x-1 items-center text-white py-2.5 px-3.5 text-sm font-regular rounded-sm"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
disabled={isSubmitting || (!isAdmin && groups.length === 0)}
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<PlusCircleIcon size={16} className="text-indigo-100" />
|
||||
Create
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
type formType = IsPublicGroupSelectorFormType & {
|
||||
name: string;
|
||||
[key: string]: any; // For additional credential fields
|
||||
};
|
||||
|
||||
export default function CreateCredential({
|
||||
hideSource,
|
||||
@ -52,6 +91,29 @@ export default function CreateCredential({
|
||||
// Mutating parent state
|
||||
refresh?: () => void;
|
||||
}) {
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
} else {
|
||||
console.error("Failed to fetch current user");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current user:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (
|
||||
values: formType,
|
||||
formikHelpers: FormikHelpers<formType>,
|
||||
@ -68,12 +130,14 @@ export default function CreateCredential({
|
||||
setSubmitting(true);
|
||||
formikHelpers.setSubmitting(true);
|
||||
|
||||
const { name, ...credentialValues } = values;
|
||||
const { name, is_public, groups, ...credentialValues } = values;
|
||||
|
||||
try {
|
||||
const response = await submitCredential({
|
||||
credential_json: credentialValues,
|
||||
admin_public: true,
|
||||
curator_public: is_public,
|
||||
groups: groups,
|
||||
name: name,
|
||||
source: sourceType,
|
||||
});
|
||||
@ -88,7 +152,7 @@ export default function CreateCredential({
|
||||
if (action === "createAndSwap") {
|
||||
onSwap(credential, swapConnector.id);
|
||||
} else {
|
||||
setPopup({ type: "success", message: "Created new credneital!!" });
|
||||
setPopup({ type: "success", message: "Created new credential!" });
|
||||
setTimeout(() => setPopup(null), 4000);
|
||||
}
|
||||
onClose();
|
||||
@ -120,14 +184,19 @@ export default function CreateCredential({
|
||||
return <GDriveMain />;
|
||||
}
|
||||
|
||||
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
||||
const credentialTemplate: dictionaryType = credentialTemplates[sourceType];
|
||||
const validationSchema = createValidationSchema(credentialTemplate);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: "",
|
||||
}}
|
||||
initialValues={
|
||||
{
|
||||
name: "",
|
||||
is_public: isAdmin,
|
||||
groups: [],
|
||||
} as formType
|
||||
}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={() => {}} // This will be overridden by our custom submit handlers
|
||||
>
|
||||
@ -167,21 +236,36 @@ export default function CreateCredential({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!swapConnector && (
|
||||
<div className="flex justify-between w-full">
|
||||
<Button
|
||||
className="bg-indigo-500 hover:bg-indigo-400"
|
||||
onClick={() =>
|
||||
handleSubmit(formikProps.values, formikProps, "create")
|
||||
}
|
||||
type="button"
|
||||
disabled={formikProps.isSubmitting}
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<PlusCircleIcon size={16} className="text-indigo-100" />
|
||||
Create
|
||||
</div>
|
||||
</Button>
|
||||
{!swapConnector && !isLoading && (
|
||||
<div className="mt-4 flex flex-col sm:flex-row justify-between items-end">
|
||||
<div className="w-full sm:w-3/4 mb-4 sm:mb-0">
|
||||
{isPaidEnterpriseFeaturesEnabled && (
|
||||
<div className="flex flex-col items-start">
|
||||
{isAdmin && (
|
||||
<AdvancedOptionsToggle
|
||||
showAdvancedOptions={showAdvancedOptions}
|
||||
setShowAdvancedOptions={setShowAdvancedOptions}
|
||||
/>
|
||||
)}
|
||||
{(showAdvancedOptions || !isAdmin) && (
|
||||
<IsPublicGroupSelector
|
||||
formikProps={formikProps}
|
||||
objectName="credential"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full sm:w-1/4">
|
||||
<CreateButton
|
||||
onClick={() =>
|
||||
handleSubmit(formikProps.values, formikProps, "create")
|
||||
}
|
||||
isSubmitting={formikProps.isSubmitting}
|
||||
isAdmin={isAdmin}
|
||||
groups={formikProps.values.groups}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
@ -1,16 +1,13 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { Button, Text, Badge } from "@tremor/react";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import { FaCreativeCommons } from "react-icons/fa";
|
||||
import {
|
||||
EditIcon,
|
||||
NewChatIcon,
|
||||
NewIconTest,
|
||||
SwapIcon,
|
||||
TrashIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { getSourceDisplayName } from "@/lib/sources";
|
||||
import {
|
||||
ConfluenceCredentialJson,
|
||||
Credential,
|
||||
@ -19,12 +16,14 @@ import { Connector } from "@/lib/connectors/connectors";
|
||||
|
||||
const CredentialSelectionTable = ({
|
||||
credentials,
|
||||
editableCredentials,
|
||||
onEditCredential,
|
||||
onSelectCredential,
|
||||
currentCredentialId,
|
||||
onDeleteCredential,
|
||||
}: {
|
||||
credentials: Credential<any>[];
|
||||
editableCredentials: Credential<any>[];
|
||||
onSelectCredential: (credential: Credential<any> | null) => void;
|
||||
currentCredentialId?: number;
|
||||
onDeleteCredential: (credential: Credential<any>) => void;
|
||||
@ -34,13 +33,23 @@ const CredentialSelectionTable = ({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const allCredentials = React.useMemo(() => {
|
||||
const credMap = new Map(editableCredentials.map((cred) => [cred.id, cred]));
|
||||
credentials.forEach((cred) => {
|
||||
if (!credMap.has(cred.id)) {
|
||||
credMap.set(cred.id, cred);
|
||||
}
|
||||
});
|
||||
return Array.from(credMap.values());
|
||||
}, [credentials, editableCredentials]);
|
||||
|
||||
const handleSelectCredential = (credentialId: number) => {
|
||||
const newSelectedId =
|
||||
selectedCredentialId === credentialId ? null : credentialId;
|
||||
setSelectedCredentialId(newSelectedId);
|
||||
|
||||
const selectedCredential =
|
||||
credentials.find((cred) => cred.id === newSelectedId) || null;
|
||||
allCredentials.find((cred) => cred.id === newSelectedId) || null;
|
||||
onSelectCredential(selectedCredential);
|
||||
};
|
||||
|
||||
@ -60,12 +69,15 @@ const CredentialSelectionTable = ({
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{credentials.length > 0 && (
|
||||
{allCredentials.length > 0 && (
|
||||
<tbody>
|
||||
{credentials.map((credential, ind) => {
|
||||
{allCredentials.map((credential, ind) => {
|
||||
const selected = currentCredentialId
|
||||
? credential.id == (selectedCredentialId || currentCredentialId)
|
||||
: false;
|
||||
const editable = editableCredentials.some(
|
||||
(editableCredential) => editableCredential.id === credential.id
|
||||
);
|
||||
return (
|
||||
<tr key={credential.id} className="border-b hover:bg-gray-50">
|
||||
<td className="min-w-[60px] p-2">
|
||||
@ -92,7 +104,7 @@ const CredentialSelectionTable = ({
|
||||
</td>
|
||||
<td className="pt-3 flex gap-x-2 content-center mt-auto">
|
||||
<button
|
||||
disabled={selected}
|
||||
disabled={selected || !editable}
|
||||
onClick={async () => {
|
||||
onDeleteCredential(credential);
|
||||
}}
|
||||
@ -102,6 +114,7 @@ const CredentialSelectionTable = ({
|
||||
</button>
|
||||
{onEditCredential && (
|
||||
<button
|
||||
disabled={!editable}
|
||||
onClick={() => onEditCredential(credential)}
|
||||
className="cursor-pointer my-auto"
|
||||
>
|
||||
@ -116,7 +129,7 @@ const CredentialSelectionTable = ({
|
||||
)}
|
||||
</table>
|
||||
|
||||
{credentials.length == 0 && (
|
||||
{allCredentials.length == 0 && (
|
||||
<p className="mt-4"> No credentials exist for this connector!</p>
|
||||
)}
|
||||
</div>
|
||||
@ -128,6 +141,7 @@ export default function ModifyCredential({
|
||||
showIfEmpty,
|
||||
attachedConnector,
|
||||
credentials,
|
||||
editableCredentials,
|
||||
source,
|
||||
defaultedCredential,
|
||||
|
||||
@ -143,6 +157,7 @@ export default function ModifyCredential({
|
||||
attachedConnector?: Connector<any>;
|
||||
defaultedCredential?: Credential<any>;
|
||||
credentials: Credential<any>[];
|
||||
editableCredentials: Credential<any>[];
|
||||
source: ValidSources;
|
||||
|
||||
onSwitch?: (newCredential: Credential<any>) => void;
|
||||
@ -157,7 +172,7 @@ export default function ModifyCredential({
|
||||
const [confirmDeletionCredential, setConfirmDeletionCredential] =
|
||||
useState<null | Credential<any>>(null);
|
||||
|
||||
if (!credentials) {
|
||||
if (!credentials || !editableCredentials) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@ -215,6 +230,7 @@ export default function ModifyCredential({
|
||||
defaultedCredential ? defaultedCredential.id : undefined
|
||||
}
|
||||
credentials={credentials}
|
||||
editableCredentials={editableCredentials}
|
||||
onSelectCredential={(credential: Credential<any> | null) => {
|
||||
if (credential && onSwitch) {
|
||||
onSwitch(credential);
|
||||
|
42
web/src/components/modals/GenericConfirmModal.tsx
Normal file
42
web/src/components/modals/GenericConfirmModal.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { ModalWrapper } from "./ModalWrapper";
|
||||
import { BasicClickable } from "@/components/BasicClickable";
|
||||
|
||||
export const GenericConfirmModal = ({
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<ModalWrapper onClose={onClose}>
|
||||
<div className="max-w-full">
|
||||
<div className="flex mb-4">
|
||||
<h2 className="my-auto text-2xl font-bold whitespace-normal overflow-wrap-normal">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mb-4 whitespace-normal overflow-wrap-normal">{message}</p>
|
||||
<div className="flex">
|
||||
<div className="mx-auto">
|
||||
<BasicClickable onClick={onConfirm}>
|
||||
<div className="flex mx-2 items-center">
|
||||
<FiCheck className="mr-2 flex-shrink-0" />
|
||||
<span className="whitespace-normal overflow-wrap-normal">
|
||||
{confirmText}
|
||||
</span>
|
||||
</div>
|
||||
</BasicClickable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
);
|
||||
};
|
@ -7,9 +7,11 @@ import { Row } from "./interfaces";
|
||||
export function DraggableRow({
|
||||
row,
|
||||
forceDragging,
|
||||
isAdmin = true,
|
||||
}: {
|
||||
row: Row;
|
||||
forceDragging?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@ -33,11 +35,13 @@ export function DraggableRow({
|
||||
className={isDragging ? "invisible" : "bg-background"}
|
||||
>
|
||||
<TableCell>
|
||||
<DragHandle
|
||||
isDragging={isDragging || forceDragging}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<DragHandle
|
||||
isDragging={isDragging || forceDragging}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
{row.cells.map((column, ind) => (
|
||||
<TableCell key={ind}>{column}</TableCell>
|
||||
|
@ -33,10 +33,12 @@ export function DraggableTable({
|
||||
headers,
|
||||
rows,
|
||||
setRows,
|
||||
isAdmin,
|
||||
}: {
|
||||
headers: (string | JSX.Element | null)[];
|
||||
rows: Row[];
|
||||
setRows: (newRows: UniqueIdentifier[]) => void | Promise<void>;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
|
||||
const items = useMemo(() => rows?.map(({ id }) => id), [rows]);
|
||||
@ -47,17 +49,20 @@ export function DraggableTable({
|
||||
);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(event.active.id);
|
||||
if (isAdmin) {
|
||||
setActiveId(event.active.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (over !== null && active.id !== over.id) {
|
||||
const oldIndex = items.indexOf(active.id);
|
||||
const newIndex = items.indexOf(over.id);
|
||||
setRows(arrayMove(rows, oldIndex, newIndex).map((row) => row.id));
|
||||
if (isAdmin) {
|
||||
const { active, over } = event;
|
||||
if (over !== null && active.id !== over.id) {
|
||||
const oldIndex = items.indexOf(active.id);
|
||||
const newIndex = items.indexOf(over.id);
|
||||
setRows(arrayMove(rows, oldIndex, newIndex).map((row) => row.id));
|
||||
}
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
@ -95,19 +100,21 @@ export function DraggableTable({
|
||||
<TableBody>
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
{rows.map((row) => {
|
||||
return <DraggableRow key={row.id} row={row} />;
|
||||
return <DraggableRow key={row.id} row={row} isAdmin={isAdmin} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{selectedRow && (
|
||||
<Table className="overflow-y-visible">
|
||||
<TableBody>
|
||||
<StaticRow key={selectedRow.id} row={selectedRow} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</DragOverlay>
|
||||
{isAdmin && (
|
||||
<DragOverlay>
|
||||
{selectedRow && (
|
||||
<Table className="overflow-y-visible">
|
||||
<TableBody>
|
||||
<StaticRow key={selectedRow.id} row={selectedRow} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</DragOverlay>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
|
@ -1,15 +1,13 @@
|
||||
const userMutationFetcher = async (
|
||||
url: string,
|
||||
{ arg }: { arg: { user_email: string } }
|
||||
{ arg }: { arg: { user_email: string; new_role?: string } }
|
||||
) => {
|
||||
return fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_email: arg.user_email,
|
||||
}),
|
||||
body: JSON.stringify(arg),
|
||||
}).then(async (res) => {
|
||||
if (res.ok) return res.json();
|
||||
const errorDetail = (await res.json()).detail;
|
||||
|
@ -20,22 +20,21 @@ export async function setCCPairStatus(
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = (await response.json()).detail;
|
||||
setPopup &&
|
||||
setPopup({
|
||||
message: "Failed to update connector status - " + errorMessage,
|
||||
type: "error",
|
||||
});
|
||||
const { detail } = await response.json();
|
||||
setPopup?.({
|
||||
message: `Failed to update connector status - ${detail}`,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPopup &&
|
||||
setPopup({
|
||||
message:
|
||||
ccPairStatus === ConnectorCredentialPairStatus.ACTIVE
|
||||
? "Enabled connector!"
|
||||
: "Paused connector!",
|
||||
type: "success",
|
||||
});
|
||||
setPopup?.({
|
||||
message:
|
||||
ccPairStatus === ConnectorCredentialPairStatus.ACTIVE
|
||||
? "Enabled connector!"
|
||||
: "Paused connector!",
|
||||
type: "success",
|
||||
});
|
||||
|
||||
onUpdate && onUpdate();
|
||||
} catch (error) {
|
||||
|
@ -774,6 +774,8 @@ export interface ConnectorBase<T> {
|
||||
refresh_freq: number | null;
|
||||
prune_freq: number | null;
|
||||
indexing_start: Date | null;
|
||||
is_public?: boolean;
|
||||
groups?: number[];
|
||||
}
|
||||
|
||||
export interface Connector<T> extends ConnectorBase<T> {
|
||||
|
@ -5,11 +5,12 @@ export interface CredentialBase<T> {
|
||||
admin_public: boolean;
|
||||
source: ValidSources;
|
||||
name?: string;
|
||||
curator_public?: boolean;
|
||||
groups?: number[];
|
||||
}
|
||||
|
||||
export interface Credential<T> extends CredentialBase<T> {
|
||||
id: number;
|
||||
name?: string;
|
||||
user_id: string | null;
|
||||
time_created: string;
|
||||
time_updated: string;
|
||||
|
@ -47,7 +47,8 @@ export function linkCredential(
|
||||
connectorId: number,
|
||||
credentialId: number,
|
||||
name?: string,
|
||||
isPublic?: boolean
|
||||
isPublic?: boolean,
|
||||
groups?: number[]
|
||||
) {
|
||||
return fetch(
|
||||
`/api/manage/connector/${connectorId}/credential/${credentialId}`,
|
||||
@ -59,6 +60,7 @@ export function linkCredential(
|
||||
body: JSON.stringify({
|
||||
name: name || null,
|
||||
is_public: isPublic !== undefined ? isPublic : true,
|
||||
groups: groups || null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
@ -14,7 +14,12 @@ export enum UserStatus {
|
||||
deactivated = "deactivated",
|
||||
}
|
||||
|
||||
export type UserRole = "basic" | "admin";
|
||||
export enum UserRole {
|
||||
BASIC = "basic",
|
||||
ADMIN = "admin",
|
||||
CURATOR = "curator",
|
||||
GLOBAL_CURATOR = "global_curator",
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
@ -185,6 +190,7 @@ export interface UserGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
users: User[];
|
||||
curator_ids: string[];
|
||||
cc_pairs: CCPairDescriptor<any, any>[];
|
||||
document_sets: DocumentSet[];
|
||||
personas: Persona[];
|
||||
|
@ -4,7 +4,6 @@ export const checkUserIsNoAuthUser = (userId: string) => {
|
||||
return userId === "__no_auth_user__";
|
||||
};
|
||||
|
||||
// should be used client-side only
|
||||
export const getCurrentUser = async (): Promise<User | null> => {
|
||||
const response = await fetch("/api/me", {
|
||||
credentials: "include",
|
||||
|
Loading…
x
Reference in New Issue
Block a user