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:
hagen-danswer 2024-08-22 18:39:37 -07:00 committed by GitHub
parent 5409777e0b
commit c042a19c00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 3141 additions and 1205 deletions

View 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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -89,6 +89,11 @@ class UserByEmail(BaseModel):
user_email: str
class UserRoleUpdateRequest(BaseModel):
user_email: str
new_role: UserRole
class UserRoleResponse(BaseModel):
role: str

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &quot;title&quot; for
this Starter Message. For example,
&quot;Write an email&quot;.
</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 &quot;title&quot;
for this Starter Message. For
example, &quot;Write an email&quot;.
</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
&quot;to a client about a new
feature&quot;
</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
&quot;to a client about a new
feature&quot;
</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,
&quot;Write me an email to a client
about a new billing feature we just
released.&quot;
</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, &quot;Write me an email to
a client about a new billing feature
we just released.&quot;
</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,12 +15,12 @@ interface AdvancedFormPageProps {
const AdvancedFormPage = forwardRef<FormikProps<any>, AdvancedFormPageProps>(
(
{
setIndexingStart,
indexingStart,
setRefreshFreq,
currentRefreshFreq,
setPruneFreq,
currentPruneFreq,
currentRefreshFreq,
indexingStart,
setIndexingStart,
},
ref
) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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!"}

View File

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

View File

@ -1,4 +1,4 @@
interface DocumentSetCreationRequest {
export interface DocumentSetCreationRequest {
name: string;
description: string;
cc_pair_ids: number[];

View File

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

View File

@ -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
)}
/>
))}
</>

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[];

View 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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[];

View File

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