mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-03-26 17:51:54 +01:00
Add User Groups (a.k.a. RBAC) (#4)
This commit is contained in:
parent
92de6acc6f
commit
7503f8f37b
@ -61,6 +61,7 @@ WORKDIR /app
|
||||
|
||||
# Enterprise Version Files
|
||||
COPY ./ee /app/ee
|
||||
COPY ee.supervisord.conf /etc/supervisor/conf.d/ee.supervisord.conf
|
||||
|
||||
# Set up application files
|
||||
COPY ./danswer /app/danswer
|
||||
|
@ -151,6 +151,7 @@ def add_credential_to_connector(
|
||||
connector_id: int,
|
||||
credential_id: int,
|
||||
cc_pair_name: str | None,
|
||||
is_public: bool,
|
||||
user: User,
|
||||
db_session: Session,
|
||||
) -> StatusResponse[int]:
|
||||
@ -185,6 +186,7 @@ def add_credential_to_connector(
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
name=cc_pair_name,
|
||||
is_public=is_public,
|
||||
)
|
||||
db_session.add(association)
|
||||
db_session.commit()
|
||||
|
@ -85,6 +85,7 @@ def associate_credential_to_connector(
|
||||
connector_id=connector_id,
|
||||
credential_id=credential_id,
|
||||
cc_pair_name=metadata.name,
|
||||
is_public=metadata.is_public,
|
||||
user=user,
|
||||
db_session=db_session,
|
||||
)
|
||||
|
@ -183,6 +183,7 @@ class ConnectorCredentialPairIdentifier(BaseModel):
|
||||
|
||||
class ConnectorCredentialPairMetadata(BaseModel):
|
||||
name: str | None
|
||||
is_public: bool
|
||||
|
||||
|
||||
class ConnectorCredentialPairDescriptor(BaseModel):
|
||||
|
60
backend/ee.supervisord.conf
Normal file
60
backend/ee.supervisord.conf
Normal file
@ -0,0 +1,60 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/dev/stdout
|
||||
logfile_maxbytes=0
|
||||
|
||||
[program:indexing]
|
||||
command=python danswer/background/update.py
|
||||
stdout_logfile=/var/log/update.log
|
||||
stdout_logfile_maxbytes=52428800
|
||||
redirect_stderr=true
|
||||
autorestart=true
|
||||
|
||||
[program:celery]
|
||||
command=celery -A ee.danswer.background.celery worker --loglevel=INFO
|
||||
stdout_logfile=/var/log/celery.log
|
||||
stdout_logfile_maxbytes=52428800
|
||||
redirect_stderr=true
|
||||
autorestart=true
|
||||
|
||||
[program:file_deletion]
|
||||
command=python danswer/background/file_deletion.py
|
||||
stdout_logfile=/var/log/file_deletion.log
|
||||
stdout_logfile_maxbytes=52428800
|
||||
redirect_stderr=true
|
||||
autorestart=true
|
||||
|
||||
[program:document_set_sync]
|
||||
command=python danswer/background/document_set_sync_script.py
|
||||
stdout_logfile=/var/log/document_set_sync.log
|
||||
stdout_logfile_maxbytes=52428800
|
||||
redirect_stderr=true
|
||||
autorestart=true
|
||||
|
||||
[program:user_group_sync]
|
||||
command=python ee/danswer/background/user_group_sync_script.py
|
||||
stdout_logfile=/var/log/user_group_sync.log
|
||||
stdout_logfile_maxbytes=52428800
|
||||
redirect_stderr=true
|
||||
autorestart=true
|
||||
|
||||
# Listens for slack messages and responds with answers
|
||||
# for all channels that the DanswerBot has been added to.
|
||||
# If not setup, this will just fail 5 times and then stop.
|
||||
# More details on setup here: https://docs.danswer.dev/slack_bot_setup
|
||||
[program:slack_bot_listener]
|
||||
command=python danswer/bots/slack/listener.py
|
||||
stdout_logfile=/var/log/slack_bot_listener.log
|
||||
stdout_logfile_maxbytes=52428800
|
||||
redirect_stderr=true
|
||||
autorestart=true
|
||||
startretries=5
|
||||
startsecs=60
|
||||
|
||||
# pushes all logs from the above programs to stdout
|
||||
[program:log-redirect-handler]
|
||||
command=tail -qF /var/log/update.log /var/log/celery.log /var/log/file_deletion.log /var/log/slack_bot_listener.log /var/log/document_set_sync.log /var/log/user_group_sync.log
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
redirect_stderr=true
|
||||
autorestart=true
|
75
backend/ee/danswer/access/access.py
Normal file
75
backend/ee/danswer/access/access.py
Normal file
@ -0,0 +1,75 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.access.access import _get_acl_for_user as get_acl_for_user_without_groups
|
||||
from danswer.access.access import (
|
||||
get_access_for_documents as get_access_for_documents_without_groups,
|
||||
)
|
||||
from danswer.access.models import DocumentAccess
|
||||
from danswer.db.engine import get_sqlalchemy_engine
|
||||
from danswer.db.models import User
|
||||
from danswer.server.documents.models import ConnectorCredentialPairIdentifier
|
||||
from ee.danswer.db.user_group import fetch_user_groups_for_documents
|
||||
from ee.danswer.db.user_group import fetch_user_groups_for_user
|
||||
|
||||
|
||||
def _get_access_for_documents(
|
||||
document_ids: list[str],
|
||||
cc_pair_to_delete: ConnectorCredentialPairIdentifier | None,
|
||||
db_session: Session,
|
||||
) -> dict[str, DocumentAccess]:
|
||||
access_dict = get_access_for_documents_without_groups(
|
||||
document_ids=document_ids,
|
||||
cc_pair_to_delete=cc_pair_to_delete,
|
||||
db_session=db_session,
|
||||
)
|
||||
user_group_info = {
|
||||
document_id: group_names
|
||||
for document_id, group_names in fetch_user_groups_for_documents(
|
||||
db_session=db_session,
|
||||
document_ids=document_ids,
|
||||
cc_pair_to_delete=cc_pair_to_delete,
|
||||
)
|
||||
}
|
||||
|
||||
# overload user_ids a bit - use it for both actual User IDs + group IDs
|
||||
return {
|
||||
document_id: DocumentAccess(
|
||||
user_ids=access.user_ids.union(user_group_info.get(document_id, [])), # type: ignore
|
||||
is_public=access.is_public,
|
||||
)
|
||||
for document_id, access in access_dict.items()
|
||||
}
|
||||
|
||||
|
||||
def get_access_for_documents(
|
||||
document_ids: list[str],
|
||||
cc_pair_to_delete: ConnectorCredentialPairIdentifier | None = None,
|
||||
db_session: Session | None = None,
|
||||
) -> dict[str, DocumentAccess]:
|
||||
if db_session is None:
|
||||
with Session(get_sqlalchemy_engine()) as db_session:
|
||||
return _get_access_for_documents(
|
||||
document_ids, cc_pair_to_delete, db_session
|
||||
)
|
||||
|
||||
return _get_access_for_documents(document_ids, cc_pair_to_delete, db_session)
|
||||
|
||||
|
||||
def prefix_user_group(user_group_name: str) -> str:
|
||||
"""Prefixes a user group name to eliminate collision with user IDs.
|
||||
This assumes that user ids are prefixed with a different prefix."""
|
||||
return f"group:{user_group_name}"
|
||||
|
||||
|
||||
def _get_acl_for_user(user: User | None, db_session: Session) -> set[str]:
|
||||
"""Returns a list of ACL entries that the user has access to. This is meant to be
|
||||
used downstream to filter out documents that the user does not have access to. The
|
||||
user should have access to a document if at least one entry in the document's ACL
|
||||
matches one entry in the returned set.
|
||||
|
||||
NOTE: is imported in danswer.access.access by `fetch_versioned_implementation`
|
||||
DO NOT REMOVE."""
|
||||
user_groups = fetch_user_groups_for_user(db_session, user.id) if user else []
|
||||
return set(
|
||||
[prefix_user_group(user_group.name) for user_group in user_groups]
|
||||
).union(get_acl_for_user_without_groups(user, db_session))
|
7
backend/ee/danswer/background/celery/celery.py
Normal file
7
backend/ee/danswer/background/celery/celery.py
Normal file
@ -0,0 +1,7 @@
|
||||
from danswer.background.celery.celery import celery_app
|
||||
from ee.danswer.user_groups.sync import sync_user_groups
|
||||
|
||||
|
||||
@celery_app.task(soft_time_limit=60 * 60 * 6) # 6 hour time limit
|
||||
def sync_user_group_task(user_group_id: int) -> None:
|
||||
sync_user_groups(user_group_id=user_group_id)
|
54
backend/ee/danswer/background/user_group_sync_script.py
Normal file
54
backend/ee/danswer/background/user_group_sync_script.py
Normal file
@ -0,0 +1,54 @@
|
||||
import time
|
||||
from celery.result import AsyncResult
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.db.engine import get_sqlalchemy_engine
|
||||
from danswer.utils.logger import setup_logger
|
||||
from ee.danswer.background.celery.celery import sync_user_group_task
|
||||
from ee.danswer.db.user_group import fetch_user_groups
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
_ExistingTaskCache: dict[int, AsyncResult] = {}
|
||||
|
||||
|
||||
def _user_group_sync_loop() -> None:
|
||||
# cleanup tasks
|
||||
existing_tasks = list(_ExistingTaskCache.items())
|
||||
for user_group_id, task in existing_tasks:
|
||||
if task.ready():
|
||||
logger.info(
|
||||
f"User Group '{user_group_id}' is complete with status "
|
||||
f"{task.status}. Cleaning up."
|
||||
)
|
||||
del _ExistingTaskCache[user_group_id]
|
||||
|
||||
# kick off new tasks
|
||||
with Session(get_sqlalchemy_engine()) as db_session:
|
||||
# check if any document sets are not synced
|
||||
user_groups = fetch_user_groups(db_session=db_session, only_current=False)
|
||||
for user_group in user_groups:
|
||||
if not user_group.is_up_to_date:
|
||||
if user_group.id in _ExistingTaskCache:
|
||||
logger.info(
|
||||
f"User Group '{user_group.id}' is already syncing. Skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(f"User Group {user_group.id} is not synced. Syncing now!")
|
||||
task = sync_user_group_task.apply_async(
|
||||
kwargs=dict(user_group_id=user_group.id),
|
||||
)
|
||||
_ExistingTaskCache[user_group.id] = task
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
while True:
|
||||
start = time.monotonic()
|
||||
|
||||
_user_group_sync_loop()
|
||||
|
||||
sleep_time = 30 - (time.monotonic() - start)
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
@ -1,14 +1,18 @@
|
||||
import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy.orm import Mapped
|
||||
from sqlalchemy.orm import mapped_column
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from danswer.db.models import Base
|
||||
from danswer.db.models import ConnectorCredentialPair
|
||||
from danswer.db.models import User
|
||||
|
||||
|
||||
@ -24,3 +28,69 @@ class SamlAccount(Base):
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User")
|
||||
|
||||
|
||||
"""Tables related to RBAC"""
|
||||
|
||||
|
||||
class User__UserGroup(Base):
|
||||
__tablename__ = "user__user_group"
|
||||
|
||||
user_group_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("user_group.id"), primary_key=True
|
||||
)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"), primary_key=True)
|
||||
|
||||
|
||||
class UserGroup__ConnectorCredentialPair(Base):
|
||||
__tablename__ = "user_group__connector_credential_pair"
|
||||
|
||||
user_group_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("user_group.id"), primary_key=True
|
||||
)
|
||||
cc_pair_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("connector_credential_pair.id"), primary_key=True
|
||||
)
|
||||
# if `True`, then is part of the current state of the UserGroup
|
||||
# if `False`, then is a part of the prior state of the UserGroup
|
||||
# rows with `is_current=False` should be deleted when the UserGroup
|
||||
# is updated and should not exist for a given UserGroup if
|
||||
# `UserGroup.is_up_to_date == True`
|
||||
is_current: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
cc_pair: Mapped[ConnectorCredentialPair] = relationship(
|
||||
"ConnectorCredentialPair",
|
||||
)
|
||||
|
||||
|
||||
class UserGroup(Base):
|
||||
__tablename__ = "user_group"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String, unique=True)
|
||||
# whether or not changes to the UserGroup have been propogated to Vespa
|
||||
is_up_to_date: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# tell the sync job to clean up the group
|
||||
is_up_for_deletion: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
|
||||
users: Mapped[list[User]] = relationship(
|
||||
"User",
|
||||
secondary=User__UserGroup.__table__,
|
||||
)
|
||||
cc_pairs: Mapped[list[ConnectorCredentialPair]] = relationship(
|
||||
"ConnectorCredentialPair",
|
||||
secondary=UserGroup__ConnectorCredentialPair.__table__,
|
||||
viewonly=True,
|
||||
)
|
||||
cc_pair_relationships: Mapped[
|
||||
list[UserGroup__ConnectorCredentialPair]
|
||||
] = relationship(
|
||||
"UserGroup__ConnectorCredentialPair",
|
||||
viewonly=True,
|
||||
)
|
||||
|
298
backend/ee/danswer/db/user_group.py
Normal file
298
backend/ee/danswer/db/user_group.py
Normal file
@ -0,0 +1,298 @@
|
||||
from collections.abc import Sequence
|
||||
from operator import and_
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.db.models import ConnectorCredentialPair
|
||||
from danswer.db.models import Document
|
||||
from danswer.db.models import DocumentByConnectorCredentialPair
|
||||
from danswer.db.models import User
|
||||
from danswer.server.documents.models import ConnectorCredentialPairIdentifier
|
||||
from ee.danswer.db.models import User__UserGroup
|
||||
from ee.danswer.db.models import UserGroup
|
||||
from ee.danswer.db.models import UserGroup__ConnectorCredentialPair
|
||||
from ee.danswer.server.user_group.models import UserGroupCreate
|
||||
from ee.danswer.server.user_group.models import UserGroupUpdate
|
||||
|
||||
|
||||
def fetch_user_group(db_session: Session, user_group_id: int) -> UserGroup | None:
|
||||
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
|
||||
return db_session.scalar(stmt)
|
||||
|
||||
|
||||
def fetch_user_groups(
|
||||
db_session: Session, only_current: bool = True
|
||||
) -> Sequence[UserGroup]:
|
||||
stmt = select(UserGroup)
|
||||
if only_current:
|
||||
stmt = stmt.where(UserGroup.is_up_to_date == True) # noqa: E712
|
||||
return db_session.scalars(stmt).all()
|
||||
|
||||
|
||||
def fetch_user_groups_for_user(
|
||||
db_session: Session, user_id: UUID
|
||||
) -> Sequence[UserGroup]:
|
||||
stmt = (
|
||||
select(UserGroup)
|
||||
.join(User__UserGroup, User__UserGroup.user_group_id == UserGroup.id)
|
||||
.join(User, User.id == User__UserGroup.user_id) # type: ignore
|
||||
.where(User.id == user_id) # type: ignore
|
||||
)
|
||||
return db_session.scalars(stmt).all()
|
||||
|
||||
|
||||
def fetch_documents_for_user_group(
|
||||
db_session: Session, user_group_id: int
|
||||
) -> Sequence[Document]:
|
||||
stmt = (
|
||||
select(Document)
|
||||
.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
Document.id == DocumentByConnectorCredentialPair.id,
|
||||
)
|
||||
.join(
|
||||
ConnectorCredentialPair,
|
||||
and_(
|
||||
DocumentByConnectorCredentialPair.connector_id
|
||||
== ConnectorCredentialPair.connector_id,
|
||||
DocumentByConnectorCredentialPair.credential_id
|
||||
== ConnectorCredentialPair.credential_id,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
UserGroup__ConnectorCredentialPair,
|
||||
UserGroup__ConnectorCredentialPair.cc_pair_id == ConnectorCredentialPair.id,
|
||||
)
|
||||
.join(
|
||||
UserGroup,
|
||||
UserGroup__ConnectorCredentialPair.user_group_id == UserGroup.id,
|
||||
)
|
||||
.where(UserGroup.id == user_group_id)
|
||||
)
|
||||
return db_session.scalars(stmt).all()
|
||||
|
||||
|
||||
def fetch_user_groups_for_documents(
|
||||
db_session: Session,
|
||||
document_ids: list[str],
|
||||
cc_pair_to_delete: ConnectorCredentialPairIdentifier | None = None,
|
||||
) -> Sequence[tuple[int, list[str]]]:
|
||||
stmt = (
|
||||
select(Document.id, func.array_agg(UserGroup.name))
|
||||
.join(
|
||||
UserGroup__ConnectorCredentialPair,
|
||||
UserGroup.id == UserGroup__ConnectorCredentialPair.user_group_id,
|
||||
)
|
||||
.join(
|
||||
ConnectorCredentialPair,
|
||||
ConnectorCredentialPair.id == UserGroup__ConnectorCredentialPair.cc_pair_id,
|
||||
)
|
||||
.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
and_(
|
||||
DocumentByConnectorCredentialPair.connector_id
|
||||
== ConnectorCredentialPair.connector_id,
|
||||
DocumentByConnectorCredentialPair.credential_id
|
||||
== ConnectorCredentialPair.credential_id,
|
||||
),
|
||||
)
|
||||
.join(Document, Document.id == DocumentByConnectorCredentialPair.id)
|
||||
.where(Document.id.in_(document_ids))
|
||||
.where(UserGroup__ConnectorCredentialPair.is_current == True) # noqa: E712
|
||||
.group_by(Document.id)
|
||||
)
|
||||
|
||||
# pretend that the specified cc pair doesn't exist
|
||||
if cc_pair_to_delete is not None:
|
||||
stmt = stmt.where(
|
||||
and_(
|
||||
ConnectorCredentialPair.connector_id != cc_pair_to_delete.connector_id,
|
||||
ConnectorCredentialPair.credential_id
|
||||
!= cc_pair_to_delete.credential_id,
|
||||
)
|
||||
)
|
||||
|
||||
return db_session.execute(stmt).all() # type: ignore
|
||||
|
||||
|
||||
def _check_user_group_is_modifiable(user_group: UserGroup) -> None:
|
||||
if not user_group.is_up_to_date:
|
||||
raise ValueError(
|
||||
"Specified user group is currently syncing. Wait until the current "
|
||||
"sync has finished before editing."
|
||||
)
|
||||
|
||||
|
||||
def _add_user__user_group_relationships__no_commit(
|
||||
db_session: Session, user_group_id: int, user_ids: list[UUID]
|
||||
) -> list[User__UserGroup]:
|
||||
"""NOTE: does not commit the transaction."""
|
||||
relationships = [
|
||||
User__UserGroup(user_id=user_id, user_group_id=user_group_id)
|
||||
for user_id in user_ids
|
||||
]
|
||||
db_session.add_all(relationships)
|
||||
return relationships
|
||||
|
||||
|
||||
def _add_user_group__cc_pair_relationships__no_commit(
|
||||
db_session: Session, user_group_id: int, cc_pair_ids: list[int]
|
||||
) -> list[UserGroup__ConnectorCredentialPair]:
|
||||
"""NOTE: does not commit the transaction."""
|
||||
relationships = [
|
||||
UserGroup__ConnectorCredentialPair(
|
||||
user_group_id=user_group_id, cc_pair_id=cc_pair_id
|
||||
)
|
||||
for cc_pair_id in cc_pair_ids
|
||||
]
|
||||
db_session.add_all(relationships)
|
||||
return relationships
|
||||
|
||||
|
||||
def insert_user_group(db_session: Session, user_group: UserGroupCreate) -> UserGroup:
|
||||
db_user_group = UserGroup(name=user_group.name)
|
||||
db_session.add(db_user_group)
|
||||
db_session.flush() # give the group an ID
|
||||
|
||||
_add_user__user_group_relationships__no_commit(
|
||||
db_session=db_session,
|
||||
user_group_id=db_user_group.id,
|
||||
user_ids=user_group.user_ids,
|
||||
)
|
||||
_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,
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
return db_user_group
|
||||
|
||||
|
||||
def _cleanup_user__user_group_relationships__no_commit(
|
||||
db_session: Session, user_group_id: int
|
||||
) -> None:
|
||||
"""NOTE: does not commit the transaction."""
|
||||
user__user_group_relationships = db_session.scalars(
|
||||
select(User__UserGroup).where(User__UserGroup.user_group_id == user_group_id)
|
||||
).all()
|
||||
for user__user_group_relationship in user__user_group_relationships:
|
||||
db_session.delete(user__user_group_relationship)
|
||||
|
||||
|
||||
def _mark_user_group__cc_pair_relationships_outdated__no_commit(
|
||||
db_session: Session, user_group_id: int
|
||||
) -> None:
|
||||
"""NOTE: does not commit the transaction."""
|
||||
user_group__cc_pair_relationships = db_session.scalars(
|
||||
select(UserGroup__ConnectorCredentialPair).where(
|
||||
UserGroup__ConnectorCredentialPair.user_group_id == user_group_id
|
||||
)
|
||||
)
|
||||
for user_group__cc_pair_relationship in user_group__cc_pair_relationships:
|
||||
user_group__cc_pair_relationship.is_current = False
|
||||
|
||||
|
||||
def update_user_group(
|
||||
db_session: Session, user_group_id: int, user_group: UserGroupUpdate
|
||||
) -> UserGroup:
|
||||
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
|
||||
db_user_group = db_session.scalar(stmt)
|
||||
if db_user_group is None:
|
||||
raise ValueError(f"UserGroup with id '{user_group_id}' not found")
|
||||
|
||||
_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
|
||||
)
|
||||
|
||||
if users_updated:
|
||||
_cleanup_user__user_group_relationships__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
)
|
||||
_add_user__user_group_relationships__no_commit(
|
||||
db_session=db_session,
|
||||
user_group_id=user_group_id,
|
||||
user_ids=user_group.user_ids,
|
||||
)
|
||||
if cc_pairs_updated:
|
||||
_mark_user_group__cc_pair_relationships_outdated__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
)
|
||||
_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,
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
db_session.commit()
|
||||
return db_user_group
|
||||
|
||||
|
||||
def prepare_user_group_for_deletion(db_session: Session, user_group_id: int) -> None:
|
||||
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
|
||||
db_user_group = db_session.scalar(stmt)
|
||||
if db_user_group is None:
|
||||
raise ValueError(f"UserGroup with id '{user_group_id}' not found")
|
||||
|
||||
_check_user_group_is_modifiable(db_user_group)
|
||||
|
||||
_cleanup_user__user_group_relationships__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
)
|
||||
_mark_user_group__cc_pair_relationships_outdated__no_commit(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
)
|
||||
db_user_group.is_up_to_date = False
|
||||
db_user_group.is_up_for_deletion = True
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def _cleanup_user_group__cc_pair_relationships__no_commit(
|
||||
db_session: Session, user_group_id: int, outdated_only: bool
|
||||
) -> None:
|
||||
"""NOTE: does not commit the transaction."""
|
||||
stmt = select(UserGroup__ConnectorCredentialPair).where(
|
||||
UserGroup__ConnectorCredentialPair.user_group_id == user_group_id
|
||||
)
|
||||
if outdated_only:
|
||||
stmt = stmt.where(
|
||||
UserGroup__ConnectorCredentialPair.is_current == False # noqa: E712
|
||||
)
|
||||
user_group__cc_pair_relationships = db_session.scalars(stmt)
|
||||
for user_group__cc_pair_relationship in user_group__cc_pair_relationships:
|
||||
db_session.delete(user_group__cc_pair_relationship)
|
||||
|
||||
|
||||
def mark_user_group_as_synced(db_session: Session, user_group: UserGroup) -> None:
|
||||
# cleanup outdated relationships
|
||||
_cleanup_user_group__cc_pair_relationships__no_commit(
|
||||
db_session=db_session, user_group_id=user_group.id, outdated_only=True
|
||||
)
|
||||
user_group.is_up_to_date = True
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def delete_user_group(db_session: Session, user_group: UserGroup) -> None:
|
||||
_cleanup_user__user_group_relationships__no_commit(
|
||||
db_session=db_session, user_group_id=user_group.id
|
||||
)
|
||||
_cleanup_user_group__cc_pair_relationships__no_commit(
|
||||
db_session=db_session,
|
||||
user_group_id=user_group.id,
|
||||
outdated_only=False,
|
||||
)
|
||||
db_session.delete(user_group)
|
||||
db_session.commit()
|
@ -17,6 +17,7 @@ from danswer.utils.logger import setup_logger
|
||||
from danswer.utils.variable_functionality import global_version
|
||||
from ee.danswer.configs.app_configs import OPENID_CONFIG_URL
|
||||
from ee.danswer.server.saml import router as saml_router
|
||||
from ee.danswer.server.user_group.api import router as user_group_router
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@ -51,6 +52,9 @@ def get_ee_application() -> FastAPI:
|
||||
elif AUTH_TYPE == AuthType.SAML:
|
||||
application.include_router(saml_router)
|
||||
|
||||
# RBAC / group access control
|
||||
application.include_router(user_group_router)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
|
71
backend/ee/danswer/server/user_group/api.py
Normal file
71
backend/ee/danswer/server/user_group/api.py
Normal file
@ -0,0 +1,71 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import danswer.db.models as db_models
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.db.engine import get_session
|
||||
from ee.danswer.db.user_group import fetch_user_groups
|
||||
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_group
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/manage")
|
||||
|
||||
|
||||
@router.get("/admin/user-group")
|
||||
def list_user_groups(
|
||||
_: db_models.User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[UserGroup]:
|
||||
user_groups = fetch_user_groups(db_session, only_current=False)
|
||||
return [UserGroup.from_model(user_group) for user_group in user_groups]
|
||||
|
||||
|
||||
@router.post("/admin/user-group")
|
||||
def create_user_group(
|
||||
user_group: UserGroupCreate,
|
||||
_: db_models.User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserGroup:
|
||||
try:
|
||||
db_user_group = insert_user_group(db_session, user_group)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"User group with name '{user_group.name}' already exists. Please "
|
||||
+ "choose a different name.",
|
||||
)
|
||||
return UserGroup.from_model(db_user_group)
|
||||
|
||||
|
||||
@router.patch("/admin/user-group/{user_group_id}")
|
||||
def patch_user_group(
|
||||
user_group_id: int,
|
||||
user_group: UserGroupUpdate,
|
||||
_: db_models.User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> UserGroup:
|
||||
try:
|
||||
return UserGroup.from_model(
|
||||
update_user_group(db_session, user_group_id, user_group)
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/admin/user-group/{user_group_id}")
|
||||
def delete_user_group(
|
||||
user_group_id: int,
|
||||
_: db_models.User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
try:
|
||||
prepare_user_group_for_deletion(db_session, user_group_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
65
backend/ee/danswer/server/user_group/models.py
Normal file
65
backend/ee/danswer/server/user_group/models.py
Normal file
@ -0,0 +1,65 @@
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from danswer.server.documents.models import (
|
||||
ConnectorCredentialPairDescriptor,
|
||||
ConnectorSnapshot,
|
||||
CredentialSnapshot,
|
||||
)
|
||||
from danswer.server.manage.models import UserInfo
|
||||
|
||||
from ee.danswer.db.models import UserGroup as UserGroupModel
|
||||
|
||||
|
||||
class UserGroup(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
users: list[UserInfo]
|
||||
cc_pairs: list[ConnectorCredentialPairDescriptor]
|
||||
is_up_to_date: bool
|
||||
is_up_for_deletion: bool
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, document_set_model: UserGroupModel) -> "UserGroup":
|
||||
return cls(
|
||||
id=document_set_model.id,
|
||||
name=document_set_model.name,
|
||||
users=[
|
||||
UserInfo(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
is_active=user.is_active,
|
||||
is_superuser=user.is_superuser,
|
||||
is_verified=user.is_verified,
|
||||
role=user.role,
|
||||
)
|
||||
for user in document_set_model.users
|
||||
],
|
||||
cc_pairs=[
|
||||
ConnectorCredentialPairDescriptor(
|
||||
id=cc_pair_relationship.cc_pair.id,
|
||||
name=cc_pair_relationship.cc_pair.name,
|
||||
connector=ConnectorSnapshot.from_connector_db_model(
|
||||
cc_pair_relationship.cc_pair.connector
|
||||
),
|
||||
credential=CredentialSnapshot.from_credential_db_model(
|
||||
cc_pair_relationship.cc_pair.credential
|
||||
),
|
||||
)
|
||||
for cc_pair_relationship in document_set_model.cc_pair_relationships
|
||||
if cc_pair_relationship.is_current
|
||||
],
|
||||
is_up_to_date=document_set_model.is_up_to_date,
|
||||
is_up_for_deletion=document_set_model.is_up_for_deletion,
|
||||
)
|
||||
|
||||
|
||||
class UserGroupCreate(BaseModel):
|
||||
name: str
|
||||
user_ids: list[UUID]
|
||||
cc_pair_ids: list[int]
|
||||
|
||||
|
||||
class UserGroupUpdate(BaseModel):
|
||||
user_ids: list[UUID]
|
||||
cc_pair_ids: list[int]
|
70
backend/ee/danswer/user_groups/sync.py
Normal file
70
backend/ee/danswer/user_groups/sync.py
Normal file
@ -0,0 +1,70 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.document_index.factory import get_default_document_index
|
||||
from danswer.document_index.interfaces import DocumentIndex
|
||||
from danswer.document_index.interfaces import UpdateRequest
|
||||
from danswer.db.document import prepare_to_modify_documents
|
||||
from danswer.db.engine import get_sqlalchemy_engine
|
||||
from danswer.utils.batching import batch_generator
|
||||
from danswer.utils.logger import setup_logger
|
||||
from ee.danswer.access.access import get_access_for_documents
|
||||
from ee.danswer.db.user_group import delete_user_group
|
||||
from ee.danswer.db.user_group import fetch_documents_for_user_group
|
||||
from ee.danswer.db.user_group import fetch_user_group
|
||||
from ee.danswer.db.user_group import mark_user_group_as_synced
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
_SYNC_BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def _sync_user_group_batch(
|
||||
document_ids: list[str], document_index: DocumentIndex
|
||||
) -> None:
|
||||
logger.debug(f"Syncing document sets for: {document_ids}")
|
||||
# begin a transaction, release lock at the end
|
||||
with Session(get_sqlalchemy_engine()) as db_session:
|
||||
# acquires a lock on the documents so that no other process can modify them
|
||||
prepare_to_modify_documents(db_session=db_session, document_ids=document_ids)
|
||||
|
||||
# get current state of document sets for these documents
|
||||
document_id_to_access = get_access_for_documents(
|
||||
document_ids=document_ids, db_session=db_session
|
||||
)
|
||||
|
||||
# update Vespa
|
||||
document_index.update(
|
||||
update_requests=[
|
||||
UpdateRequest(
|
||||
document_ids=[document_id],
|
||||
access=document_id_to_access[document_id],
|
||||
)
|
||||
for document_id in document_ids
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def sync_user_groups(user_group_id: int) -> None:
|
||||
"""Sync the status of Postgres for the specified user group"""
|
||||
document_index = get_default_document_index()
|
||||
with Session(get_sqlalchemy_engine()) as db_session:
|
||||
user_group = fetch_user_group(
|
||||
db_session=db_session, user_group_id=user_group_id
|
||||
)
|
||||
if user_group is None:
|
||||
raise ValueError(f"User group '{user_group_id}' does not exist")
|
||||
|
||||
documents_to_update = fetch_documents_for_user_group(
|
||||
db_session=db_session,
|
||||
user_group_id=user_group_id,
|
||||
)
|
||||
for document_batch in batch_generator(documents_to_update, _SYNC_BATCH_SIZE):
|
||||
_sync_user_group_batch(
|
||||
document_ids=[document.id for document in document_batch],
|
||||
document_index=document_index,
|
||||
)
|
||||
|
||||
if user_group.is_up_for_deletion:
|
||||
delete_user_group(db_session=db_session, user_group=user_group)
|
||||
else:
|
||||
mark_user_group_as_synced(db_session=db_session, user_group=user_group)
|
@ -100,7 +100,7 @@ services:
|
||||
build:
|
||||
context: ../../backend
|
||||
dockerfile: Dockerfile
|
||||
command: /usr/bin/supervisord
|
||||
command: /usr/bin/supervisord -c /etc/supervisor/conf.d/ee.supervisord.conf
|
||||
depends_on:
|
||||
- relational_db
|
||||
- index
|
||||
@ -203,6 +203,7 @@ services:
|
||||
- NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
|
||||
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
|
||||
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
|
||||
- NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED:-true}
|
||||
depends_on:
|
||||
- api_server
|
||||
restart: always
|
||||
|
@ -35,7 +35,7 @@ services:
|
||||
build:
|
||||
context: ../../backend
|
||||
dockerfile: Dockerfile
|
||||
command: /usr/bin/supervisord
|
||||
command: /usr/bin/supervisord -c /etc/supervisor/conf.d/ee.supervisord.conf
|
||||
depends_on:
|
||||
- relational_db
|
||||
- index
|
||||
@ -70,6 +70,7 @@ services:
|
||||
- NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
|
||||
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
|
||||
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
|
||||
- NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED:-true}
|
||||
depends_on:
|
||||
- api_server
|
||||
restart: always
|
||||
|
@ -16,7 +16,7 @@ spec:
|
||||
- name: background
|
||||
image: danswer/danswer-ee-backend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["/usr/bin/supervisord"]
|
||||
command: ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/ee.supervisord.conf"]
|
||||
# There are some extra values since this is shared between services
|
||||
# There are no conflicts though, extra env variables are simply ignored
|
||||
envFrom:
|
||||
|
@ -36,3 +36,5 @@ spec:
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: env-configmap
|
||||
args:
|
||||
- "NEXT_PUBLIC_EE_ENABLED=true"
|
@ -53,6 +53,8 @@ ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PRED
|
||||
|
||||
ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS
|
||||
ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS}
|
||||
ARG NEXT_PUBLIC_EE_ENABLED
|
||||
ENV NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED}
|
||||
|
||||
ARG NEXT_PUBLIC_DISABLE_LOGOUT
|
||||
ENV NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT}
|
||||
@ -101,6 +103,8 @@ ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PRED
|
||||
|
||||
ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS
|
||||
ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS}
|
||||
ARG NEXT_PUBLIC_EE_ENABLED
|
||||
ENV NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED}
|
||||
|
||||
ARG NEXT_PUBLIC_DISABLE_LOGOUT
|
||||
ENV NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT}
|
||||
|
@ -9,17 +9,31 @@ const nextConfig = {
|
||||
output: "standalone",
|
||||
swcMinify: true,
|
||||
rewrites: async () => {
|
||||
const eeRedirects =
|
||||
process.env.NEXT_PUBLIC_EE_ENABLED === "true"
|
||||
? [
|
||||
{
|
||||
source: "/admin/groups",
|
||||
destination: "/ee/admin/groups",
|
||||
},
|
||||
{
|
||||
source: "/admin/groups/:path*",
|
||||
destination: "/ee/admin/groups/:path*",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
// In production, something else (nginx in the one box setup) should take
|
||||
// care of this rewrite. TODO (chris): better support setups where
|
||||
// web_server and api_server are on different machines.
|
||||
if (process.env.NODE_ENV === "production") return [];
|
||||
if (process.env.NODE_ENV === "production") return eeRedirects;
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: "http://127.0.0.1:8080/:path*", // Proxy to Backend
|
||||
},
|
||||
];
|
||||
].concat(eeRedirects);
|
||||
},
|
||||
redirects: async () => {
|
||||
// In production, something else (nginx in the one box setup) should take
|
||||
|
@ -17,7 +17,7 @@ import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
import { getDocsProcessedPerMinute } from "@/lib/indexAttempt";
|
||||
import Link from "next/link";
|
||||
import { isCurrentlyDeleting } from "@/lib/documentDeletion";
|
||||
import { FiEdit } from "react-icons/fi";
|
||||
import { FiCheck, FiEdit, FiXCircle } from "react-icons/fi";
|
||||
|
||||
const NUM_IN_PAGE = 20;
|
||||
|
||||
@ -86,6 +86,7 @@ export function CCPairIndexingStatusTable({
|
||||
<TableRow>
|
||||
<TableHeaderCell>Connector</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Is Public</TableHeaderCell>
|
||||
<TableHeaderCell>Last Indexed</TableHeaderCell>
|
||||
<TableHeaderCell>Docs Indexed</TableHeaderCell>
|
||||
</TableRow>
|
||||
@ -116,6 +117,13 @@ export function CCPairIndexingStatusTable({
|
||||
ccPairsIndexingStatus={ccPairsIndexingStatus}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ccPairsIndexingStatus.public_doc ? (
|
||||
<FiCheck className="my-auto text-emerald-600" size="18" />
|
||||
) : (
|
||||
<FiXCircle className="my-auto text-red-600" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{timeAgo(ccPairsIndexingStatus?.last_success) || "-"}
|
||||
</TableCell>
|
||||
|
36
web/src/app/ee/LICENSE
Normal file
36
web/src/app/ee/LICENSE
Normal file
@ -0,0 +1,36 @@
|
||||
The DanswerAI Enterprise license (the “Enterprise License”)
|
||||
Copyright (c) 2023 DanswerAI, Inc.
|
||||
|
||||
With regard to the Danswer Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the DanswerAI Subscription Terms of Service, available
|
||||
at https://danswer.ai/terms (the “Enterprise Terms”), or other
|
||||
agreement governing the use of the Software, as agreed by you and DanswerAI,
|
||||
and otherwise have a valid Danswer Enterprise license for the
|
||||
correct number of user seats. Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that DanswerAI
|
||||
and/or its licensors (as applicable) retain all right, title and interest in and
|
||||
to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid Danswer Enterprise license for the correct
|
||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain
|
||||
all right, title and interest in and to all such modifications. You are not
|
||||
granted any other rights beyond what is expressly stated herein. Subject to the
|
||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Danswer Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
64
web/src/app/ee/admin/groups/ConnectorEditor.tsx
Normal file
64
web/src/app/ee/admin/groups/ConnectorEditor.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { ConnectorIndexingStatus } from "@/lib/types";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
|
||||
interface ConnectorEditorProps {
|
||||
selectedCCPairIds: number[];
|
||||
setSetCCPairIds: (ccPairId: number[]) => void;
|
||||
allCCPairs: ConnectorIndexingStatus<any, any>[];
|
||||
}
|
||||
|
||||
export const ConnectorEditor = ({
|
||||
selectedCCPairIds,
|
||||
setSetCCPairIds,
|
||||
allCCPairs,
|
||||
}: ConnectorEditorProps) => {
|
||||
return (
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
{allCCPairs
|
||||
// remove public docs, since they don't make sense as part of a group
|
||||
.filter((ccPair) => !ccPair.public_doc)
|
||||
.map((ccPair) => {
|
||||
const ind = selectedCCPairIds.indexOf(ccPair.cc_pair_id);
|
||||
let isSelected = ind !== -1;
|
||||
return (
|
||||
<div
|
||||
key={`${ccPair.connector.id}-${ccPair.credential.id}`}
|
||||
className={
|
||||
`
|
||||
px-3
|
||||
py-1
|
||||
rounded-lg
|
||||
border
|
||||
border-gray-700
|
||||
w-fit
|
||||
flex
|
||||
cursor-pointer ` +
|
||||
(isSelected ? " bg-gray-600" : " hover:bg-gray-700")
|
||||
}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSetCCPairIds(
|
||||
selectedCCPairIds.filter(
|
||||
(ccPairId) => ccPairId !== ccPair.cc_pair_id
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setSetCCPairIds([...selectedCCPairIds, ccPair.cc_pair_id]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="my-auto">
|
||||
<ConnectorTitle
|
||||
connector={ccPair.connector}
|
||||
ccPairId={ccPair.cc_pair_id}
|
||||
ccPairName={ccPair.name}
|
||||
isLink={false}
|
||||
showMetadata={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
94
web/src/app/ee/admin/groups/UserEditor.tsx
Normal file
94
web/src/app/ee/admin/groups/UserEditor.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { User } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
import { FiPlus, FiX } from "react-icons/fi";
|
||||
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
|
||||
import { UsersIcon } from "@/components/icons/icons";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
interface UserEditorProps {
|
||||
selectedUserIds: string[];
|
||||
setSelectedUserIds: (userIds: string[]) => void;
|
||||
allUsers: User[];
|
||||
existingUsers: User[];
|
||||
onSubmit?: (users: User[]) => void;
|
||||
}
|
||||
|
||||
export const UserEditor = ({
|
||||
selectedUserIds,
|
||||
setSelectedUserIds,
|
||||
allUsers,
|
||||
existingUsers,
|
||||
onSubmit,
|
||||
}: UserEditorProps) => {
|
||||
const selectedUsers = allUsers.filter((user) =>
|
||||
selectedUserIds.includes(user.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap gap-x-2">
|
||||
{selectedUsers.length > 0 &&
|
||||
selectedUsers.map((selectedUser) => (
|
||||
<div
|
||||
key={selectedUser.id}
|
||||
onClick={() => {
|
||||
setSelectedUserIds(
|
||||
selectedUserIds.filter((userId) => userId !== selectedUser.id)
|
||||
);
|
||||
}}
|
||||
className={`
|
||||
flex
|
||||
rounded-lg
|
||||
px-2
|
||||
py-1
|
||||
border
|
||||
border-gray-700
|
||||
hover:bg-gray-900
|
||||
cursor-pointer`}
|
||||
>
|
||||
{selectedUser.email} <FiX className="ml-1 my-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<SearchMultiSelectDropdown
|
||||
options={allUsers
|
||||
.filter(
|
||||
(user) =>
|
||||
!selectedUserIds.includes(user.id) &&
|
||||
!existingUsers.map((user) => user.id).includes(user.id)
|
||||
)
|
||||
.map((user) => {
|
||||
return {
|
||||
name: user.email,
|
||||
value: user.id,
|
||||
};
|
||||
})}
|
||||
onSelect={(option) => {
|
||||
setSelectedUserIds([
|
||||
...Array.from(new Set([...selectedUserIds, option.value])),
|
||||
]);
|
||||
}}
|
||||
itemComponent={({ option }) => (
|
||||
<div className="flex px-4 py-2.5 hover:bg-gray-800 cursor-pointer">
|
||||
<UsersIcon className="mr-2 my-auto" />
|
||||
{option.name}
|
||||
<div className="ml-auto my-auto">
|
||||
<FiPlus />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{onSubmit && (
|
||||
<Button
|
||||
className="ml-3 flex-nowrap w-32"
|
||||
onClick={() => onSubmit(selectedUsers)}
|
||||
>
|
||||
Add Users
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
145
web/src/app/ee/admin/groups/UserGroupCreationForm.tsx
Normal file
145
web/src/app/ee/admin/groups/UserGroupCreationForm.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { ArrayHelpers, FieldArray, Form, Formik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { ConnectorIndexingStatus, DocumentSet, User } from "@/lib/types";
|
||||
import { TextFormField } from "@/components/admin/connectors/Field";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
import { createUserGroup } from "./lib";
|
||||
import { UserGroup } from "./types";
|
||||
import { UserEditor } from "./UserEditor";
|
||||
import { ConnectorEditor } from "./ConnectorEditor";
|
||||
|
||||
interface UserGroupCreationFormProps {
|
||||
onClose: () => void;
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
users: User[];
|
||||
ccPairs: ConnectorIndexingStatus<any, any>[];
|
||||
existingUserGroup?: UserGroup;
|
||||
}
|
||||
|
||||
export const UserGroupCreationForm = ({
|
||||
onClose,
|
||||
setPopup,
|
||||
users,
|
||||
ccPairs,
|
||||
existingUserGroup,
|
||||
}: UserGroupCreationFormProps) => {
|
||||
const isUpdate = existingUserGroup !== undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg border border-gray-700 shadow-lg relative w-1/2 text-sm"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: existingUserGroup ? existingUserGroup.name : "",
|
||||
user_ids: [] as string[],
|
||||
cc_pair_ids: [] as number[],
|
||||
}}
|
||||
validationSchema={Yup.object().shape({
|
||||
name: Yup.string().required("Please enter a name for the group"),
|
||||
user_ids: Yup.array().of(Yup.string().required()),
|
||||
cc_pair_ids: Yup.array().of(Yup.number().required()),
|
||||
})}
|
||||
onSubmit={async (values, formikHelpers) => {
|
||||
formikHelpers.setSubmitting(true);
|
||||
let response;
|
||||
response = await createUserGroup(values);
|
||||
formikHelpers.setSubmitting(false);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: isUpdate
|
||||
? "Successfully updated user group!"
|
||||
: "Successfully created user group!",
|
||||
type: "success",
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
const responseJson = await response.json();
|
||||
const errorMsg = responseJson.detail || responseJson.message;
|
||||
setPopup({
|
||||
message: isUpdate
|
||||
? `Error updating user group - ${errorMsg}`
|
||||
: `Error creating user group - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values, setFieldValue }) => (
|
||||
<Form>
|
||||
<h2 className="text-xl font-bold mb-3 border-b border-gray-600 pt-4 pb-3 bg-gray-700 px-6">
|
||||
{isUpdate ? "Update a User Group" : "Create a new User Group"}
|
||||
</h2>
|
||||
<div className="p-4">
|
||||
<TextFormField
|
||||
name="name"
|
||||
label="Name:"
|
||||
placeholder="A name for the User Group"
|
||||
disabled={isUpdate}
|
||||
autoCompleteDisabled={true}
|
||||
/>
|
||||
<div className="border-t border-gray-600 py-2" />
|
||||
<h2 className="mb-1 font-medium">
|
||||
Select which connectors this group has access to:
|
||||
</h2>
|
||||
<p className="mb-3 text-xs">
|
||||
All documents indexed by the selected connectors will be
|
||||
visible to users in this group.
|
||||
</p>
|
||||
|
||||
<ConnectorEditor
|
||||
allCCPairs={ccPairs}
|
||||
selectedCCPairIds={values.cc_pair_ids}
|
||||
setSetCCPairIds={(ccPairsIds) =>
|
||||
setFieldValue("cc_pair_ids", ccPairsIds)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="border-t border-gray-600 py-2" />
|
||||
|
||||
<h2 className="mb-1 font-medium">
|
||||
Select which Users should be a part of this Group.
|
||||
</h2>
|
||||
<p className="mb-3 text-xs">
|
||||
All selected users will be able to search through all
|
||||
documents indexed by the selected connectors.
|
||||
</p>
|
||||
<div className="mb-3 gap-2">
|
||||
<UserEditor
|
||||
selectedUserIds={values.user_ids}
|
||||
setSelectedUserIds={(userIds) =>
|
||||
setFieldValue("user_ids", userIds)
|
||||
}
|
||||
allUsers={users}
|
||||
existingUsers={[]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={
|
||||
"bg-slate-500 hover:bg-slate-700 text-white " +
|
||||
"font-bold py-2 px-4 rounded focus:outline-none " +
|
||||
"focus:shadow-outline w-full max-w-sm mx-auto"
|
||||
}
|
||||
>
|
||||
{isUpdate ? "Update!" : "Create!"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
161
web/src/app/ee/admin/groups/UserGroupsTable.tsx
Normal file
161
web/src/app/ee/admin/groups/UserGroupsTable.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { UserGroup } from "./types";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { LoadingAnimation } from "@/components/Loading";
|
||||
import { BasicTable } from "@/components/admin/connectors/BasicTable";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
import { TrashIcon } from "@/components/icons/icons";
|
||||
import { deleteUserGroup } from "./lib";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FiUser } from "react-icons/fi";
|
||||
import { User } from "@/lib/types";
|
||||
|
||||
const MAX_USERS_TO_DISPLAY = 6;
|
||||
|
||||
const SimpleUserDisplay = ({ user }: { user: User }) => {
|
||||
return (
|
||||
<div className="flex my-0.5 text-gray-200">
|
||||
<FiUser className="mr-2 my-auto" /> {user.email}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface UserGroupsTableProps {
|
||||
userGroups: UserGroup[];
|
||||
setPopup: (popupSpec: PopupSpec | null) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export const UserGroupsTable = ({
|
||||
userGroups,
|
||||
setPopup,
|
||||
refresh,
|
||||
}: UserGroupsTableProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
// sort by name for consistent ordering
|
||||
userGroups.sort((a, b) => {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
} else if (a.name > b.name) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasicTable
|
||||
columns={[
|
||||
{
|
||||
header: "Name",
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
header: "Connectors",
|
||||
key: "ccPairs",
|
||||
},
|
||||
{
|
||||
header: "Users",
|
||||
key: "users",
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
key: "status",
|
||||
},
|
||||
{
|
||||
header: "Delete",
|
||||
key: "delete",
|
||||
},
|
||||
]}
|
||||
data={userGroups
|
||||
.filter((userGroup) => !userGroup.is_up_for_deletion)
|
||||
.map((userGroup) => {
|
||||
return {
|
||||
id: userGroup.id,
|
||||
name: userGroup.name,
|
||||
ccPairs: (
|
||||
<div>
|
||||
{userGroup.cc_pairs.map((ccPairDescriptor, ind) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
ind !== userGroup.cc_pairs.length - 1 ? "mb-3" : ""
|
||||
}
|
||||
key={ccPairDescriptor.id}
|
||||
>
|
||||
<ConnectorTitle
|
||||
connector={ccPairDescriptor.connector}
|
||||
ccPairId={ccPairDescriptor.id}
|
||||
ccPairName={ccPairDescriptor.name}
|
||||
showMetadata={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
users: (
|
||||
<div>
|
||||
{userGroup.users.length <= MAX_USERS_TO_DISPLAY ? (
|
||||
userGroup.users.map((user) => {
|
||||
return <SimpleUserDisplay key={user.id} user={user} />;
|
||||
})
|
||||
) : (
|
||||
<div>
|
||||
{userGroup.users
|
||||
.slice(0, MAX_USERS_TO_DISPLAY)
|
||||
.map((user) => {
|
||||
return (
|
||||
<SimpleUserDisplay key={user.id} user={user} />
|
||||
);
|
||||
})}
|
||||
<div className="text-gray-300">
|
||||
+ {userGroup.users.length - MAX_USERS_TO_DISPLAY} more
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
status: userGroup.is_up_to_date ? (
|
||||
<div className="text-emerald-600">Up to date!</div>
|
||||
) : (
|
||||
<div className="text-gray-300 w-10">
|
||||
<LoadingAnimation text="Syncing" />
|
||||
</div>
|
||||
),
|
||||
delete: (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
const response = await deleteUserGroup(userGroup.id);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: `User Group "${userGroup.name}" deleted`,
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = (await response.json()).detail;
|
||||
setPopup({
|
||||
message: `Failed to delete User Group - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
onSelect={(data) => {
|
||||
router.push(`/admin/groups/${data.id}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
154
web/src/app/ee/admin/groups/[groupId]/AddConnectorForm.tsx
Normal file
154
web/src/app/ee/admin/groups/[groupId]/AddConnectorForm.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { UsersIcon } from "@/components/icons/icons";
|
||||
import { useState } from "react";
|
||||
import { FiPlus, FiX } from "react-icons/fi";
|
||||
import { updateUserGroup } from "./lib";
|
||||
import { UserGroup } from "../types";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { Connector, ConnectorIndexingStatus } from "@/lib/types";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
|
||||
interface AddConnectorFormProps {
|
||||
ccPairs: ConnectorIndexingStatus<any, any>[];
|
||||
userGroup: UserGroup;
|
||||
onClose: () => void;
|
||||
setPopup: (popupSpec: PopupSpec) => void;
|
||||
}
|
||||
|
||||
export const AddConnectorForm: React.FC<AddConnectorFormProps> = ({
|
||||
ccPairs,
|
||||
userGroup,
|
||||
onClose,
|
||||
setPopup,
|
||||
}) => {
|
||||
const [selectedCCPairIds, setSelectedCCPairIds] = useState<number[]>([]);
|
||||
|
||||
const selectedCCPairs = ccPairs.filter((ccPair) =>
|
||||
selectedCCPairIds.includes(ccPair.cc_pair_id)
|
||||
);
|
||||
return (
|
||||
<Modal title="Add New Connector" onOutsideClick={() => onClose()}>
|
||||
<div className="px-6 pt-4 pb-12">
|
||||
<div className="mb-2 flex flex-wrap gap-x-2">
|
||||
{selectedCCPairs.length > 0 &&
|
||||
selectedCCPairs.map((ccPair) => (
|
||||
<div
|
||||
key={ccPair.cc_pair_id}
|
||||
onClick={() => {
|
||||
setSelectedCCPairIds(
|
||||
selectedCCPairIds.filter(
|
||||
(ccPairId) => ccPairId !== ccPair.cc_pair_id
|
||||
)
|
||||
);
|
||||
}}
|
||||
className={`
|
||||
flex
|
||||
rounded-lg
|
||||
px-2
|
||||
py-1
|
||||
my-1
|
||||
border
|
||||
border-gray-700
|
||||
hover:bg-gray-900
|
||||
cursor-pointer`}
|
||||
>
|
||||
<ConnectorTitle
|
||||
ccPairId={ccPair.cc_pair_id}
|
||||
ccPairName={ccPair.name}
|
||||
connector={ccPair.connector}
|
||||
isLink={false}
|
||||
showMetadata={false}
|
||||
/>
|
||||
<FiX className="ml-1 my-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<SearchMultiSelectDropdown
|
||||
options={ccPairs
|
||||
.filter(
|
||||
(ccPair) =>
|
||||
!selectedCCPairIds.includes(ccPair.cc_pair_id) &&
|
||||
!userGroup.cc_pairs
|
||||
.map((userGroupCCPair) => userGroupCCPair.id)
|
||||
.includes(ccPair.cc_pair_id)
|
||||
)
|
||||
// remove public docs, since they don't make sense as part of a group
|
||||
.filter((ccPair) => !ccPair.public_doc)
|
||||
.map((ccPair) => {
|
||||
return {
|
||||
name: ccPair.name?.toString() || "",
|
||||
value: ccPair.cc_pair_id?.toString(),
|
||||
metadata: {
|
||||
ccPairId: ccPair.cc_pair_id,
|
||||
connector: ccPair.connector,
|
||||
},
|
||||
};
|
||||
})}
|
||||
onSelect={(option) => {
|
||||
setSelectedCCPairIds([
|
||||
...Array.from(
|
||||
new Set([...selectedCCPairIds, parseInt(option.value)])
|
||||
),
|
||||
]);
|
||||
}}
|
||||
itemComponent={({ option }) => (
|
||||
<div className="flex px-4 py-2.5 hover:bg-gray-800 cursor-pointer">
|
||||
<div className="my-auto">
|
||||
<ConnectorTitle
|
||||
ccPairId={option?.metadata?.ccPairId as number}
|
||||
ccPairName={option.name}
|
||||
connector={option?.metadata?.connector as Connector<any>}
|
||||
isLink={false}
|
||||
showMetadata={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto my-auto">
|
||||
<FiPlus />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className="ml-3 flex-nowrap w-48"
|
||||
onClick={async () => {
|
||||
const newCCPairIds = [
|
||||
...Array.from(
|
||||
new Set(
|
||||
userGroup.cc_pairs
|
||||
.map((ccPair) => ccPair.id)
|
||||
.concat(selectedCCPairIds)
|
||||
)
|
||||
),
|
||||
];
|
||||
const response = await updateUserGroup(userGroup.id, {
|
||||
user_ids: userGroup.users.map((user) => user.id),
|
||||
cc_pair_ids: newCCPairIds,
|
||||
});
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: "Successfully added users to group",
|
||||
type: "success",
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
const responseJson = await response.json();
|
||||
const errorMsg = responseJson.detail || responseJson.message;
|
||||
setPopup({
|
||||
message: `Failed to add users to group - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Connectors
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
66
web/src/app/ee/admin/groups/[groupId]/AddMemberForm.tsx
Normal file
66
web/src/app/ee/admin/groups/[groupId]/AddMemberForm.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { updateUserGroup } from "./lib";
|
||||
import { UserGroup } from "../types";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { User } from "@/lib/types";
|
||||
import { UserEditor } from "../UserEditor";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AddMemberFormProps {
|
||||
users: User[];
|
||||
userGroup: UserGroup;
|
||||
onClose: () => void;
|
||||
setPopup: (popupSpec: PopupSpec) => void;
|
||||
}
|
||||
|
||||
export const AddMemberForm: React.FC<AddMemberFormProps> = ({
|
||||
users,
|
||||
userGroup,
|
||||
onClose,
|
||||
setPopup,
|
||||
}) => {
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<Modal title="Add New User" onOutsideClick={() => onClose()}>
|
||||
<div className="px-6 pt-4 pb-12">
|
||||
<UserEditor
|
||||
selectedUserIds={selectedUserIds}
|
||||
setSelectedUserIds={setSelectedUserIds}
|
||||
allUsers={users}
|
||||
existingUsers={userGroup.users}
|
||||
onSubmit={async (selectedUsers) => {
|
||||
const newUserIds = [
|
||||
...Array.from(
|
||||
new Set(
|
||||
userGroup.users
|
||||
.map((user) => user.id)
|
||||
.concat(selectedUsers.map((user) => user.id))
|
||||
)
|
||||
),
|
||||
];
|
||||
const response = await updateUserGroup(userGroup.id, {
|
||||
user_ids: newUserIds,
|
||||
cc_pair_ids: userGroup.cc_pairs.map((ccPair) => ccPair.id),
|
||||
});
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message: "Successfully added users to group",
|
||||
type: "success",
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
const responseJson = await response.json();
|
||||
const errorMsg = responseJson.detail || responseJson.message;
|
||||
setPopup({
|
||||
message: `Failed to add users to group - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
225
web/src/app/ee/admin/groups/[groupId]/GroupDisplay.tsx
Normal file
225
web/src/app/ee/admin/groups/[groupId]/GroupDisplay.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useState } from "react";
|
||||
import { UserGroup } from "../types";
|
||||
import { BasicTable } from "@/components/admin/connectors/BasicTable";
|
||||
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
|
||||
import { Button } from "@/components/Button";
|
||||
import { AddMemberForm } from "./AddMemberForm";
|
||||
import { TrashIcon } from "@/components/icons/icons";
|
||||
import { updateUserGroup } from "./lib";
|
||||
import { LoadingAnimation } from "@/components/Loading";
|
||||
import { ConnectorIndexingStatus, User } from "@/lib/types";
|
||||
import { AddConnectorForm } from "./AddConnectorForm";
|
||||
|
||||
interface GroupDisplayProps {
|
||||
users: User[];
|
||||
ccPairs: ConnectorIndexingStatus<any, any>[];
|
||||
userGroup: UserGroup;
|
||||
refreshUserGroup: () => void;
|
||||
}
|
||||
|
||||
export const GroupDisplay = ({
|
||||
users,
|
||||
ccPairs,
|
||||
userGroup,
|
||||
refreshUserGroup,
|
||||
}: GroupDisplayProps) => {
|
||||
const { popup, setPopup } = usePopup();
|
||||
const [addMemberFormVisible, setAddMemberFormVisible] = useState(false);
|
||||
const [addConnectorFormVisible, setAddConnectorFormVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{popup}
|
||||
|
||||
<div className="text-sm mb-3 flex">
|
||||
<b className="mr-1">Status:</b>{" "}
|
||||
{userGroup.is_up_to_date ? (
|
||||
<div className="text-emerald-600">Up to date</div>
|
||||
) : (
|
||||
<div className="text-gray-300">
|
||||
<LoadingAnimation text="Syncing" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full">
|
||||
<h2 className="text-xl font-bold">Users</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{userGroup.users.length > 0 ? (
|
||||
<BasicTable
|
||||
columns={[
|
||||
{
|
||||
header: "Email",
|
||||
key: "email",
|
||||
},
|
||||
{
|
||||
header: "Remove User",
|
||||
key: "remove",
|
||||
alignment: "right",
|
||||
},
|
||||
]}
|
||||
data={userGroup.users.map((user) => {
|
||||
return {
|
||||
email: <div>{user.email}</div>,
|
||||
remove: (
|
||||
<div className="flex">
|
||||
<div
|
||||
className="cursor-pointer ml-auto mr-1"
|
||||
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",
|
||||
});
|
||||
}
|
||||
refreshUserGroup();
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm">No users in this group...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={() => setAddMemberFormVisible(true)}
|
||||
disabled={!userGroup.is_up_to_date}
|
||||
>
|
||||
Add Users
|
||||
</Button>
|
||||
|
||||
{addMemberFormVisible && (
|
||||
<AddMemberForm
|
||||
users={users}
|
||||
userGroup={userGroup}
|
||||
onClose={() => {
|
||||
setAddMemberFormVisible(false);
|
||||
refreshUserGroup();
|
||||
}}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h2 className="text-xl font-bold mt-4">Connectors</h2>
|
||||
<div className="mt-2">
|
||||
{userGroup.cc_pairs.length > 0 ? (
|
||||
<BasicTable
|
||||
columns={[
|
||||
{
|
||||
header: "Connector",
|
||||
key: "connector_name",
|
||||
},
|
||||
{
|
||||
header: "Remove Connector",
|
||||
key: "delete",
|
||||
alignment: "right",
|
||||
},
|
||||
]}
|
||||
data={userGroup.cc_pairs.map((ccPair) => {
|
||||
return {
|
||||
connector_name:
|
||||
(
|
||||
<ConnectorTitle
|
||||
connector={ccPair.connector}
|
||||
ccPairId={ccPair.id}
|
||||
ccPairName={ccPair.name}
|
||||
/>
|
||||
) || "",
|
||||
delete: (
|
||||
<div className="flex">
|
||||
<div
|
||||
className="cursor-pointer ml-auto mr-1"
|
||||
onClick={async () => {
|
||||
const response = await updateUserGroup(userGroup.id, {
|
||||
user_ids: userGroup.users.map(
|
||||
(userGroupUser) => userGroupUser.id
|
||||
),
|
||||
cc_pair_ids: userGroup.cc_pairs
|
||||
.filter(
|
||||
(userGroupCCPair) =>
|
||||
userGroupCCPair.id != ccPair.id
|
||||
)
|
||||
.map((ccPair) => ccPair.id),
|
||||
});
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
message:
|
||||
"Successfully removed connector from group",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
const responseJson = await response.json();
|
||||
const errorMsg =
|
||||
responseJson.detail || responseJson.message;
|
||||
setPopup({
|
||||
message: `Error removing connector from group - ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
refreshUserGroup();
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm">No connectors in this group...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={() => setAddConnectorFormVisible(true)}
|
||||
disabled={!userGroup.is_up_to_date}
|
||||
>
|
||||
Add Connectors
|
||||
</Button>
|
||||
|
||||
{addConnectorFormVisible && (
|
||||
<AddConnectorForm
|
||||
ccPairs={ccPairs}
|
||||
userGroup={userGroup}
|
||||
onClose={() => {
|
||||
setAddConnectorFormVisible(false);
|
||||
refreshUserGroup();
|
||||
}}
|
||||
setPopup={setPopup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
12
web/src/app/ee/admin/groups/[groupId]/hook.ts
Normal file
12
web/src/app/ee/admin/groups/[groupId]/hook.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { useUserGroups } from "../hooks";
|
||||
|
||||
export const useSpecificUserGroup = (groupId: string) => {
|
||||
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
|
||||
const userGroup = data?.find((group) => group.id.toString() === groupId);
|
||||
return {
|
||||
userGroup,
|
||||
isLoading,
|
||||
error,
|
||||
refreshUserGroup: refreshUserGroups,
|
||||
};
|
||||
};
|
15
web/src/app/ee/admin/groups/[groupId]/lib.ts
Normal file
15
web/src/app/ee/admin/groups/[groupId]/lib.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { UserGroupUpdate } from "../types";
|
||||
|
||||
export const updateUserGroup = async (
|
||||
groupId: number,
|
||||
userGroup: UserGroupUpdate
|
||||
) => {
|
||||
const url = `/api/manage/admin/user-group/${groupId}`;
|
||||
return await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(userGroup),
|
||||
});
|
||||
};
|
81
web/src/app/ee/admin/groups/[groupId]/page.tsx
Normal file
81
web/src/app/ee/admin/groups/[groupId]/page.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { GroupsIcon } from "@/components/icons/icons";
|
||||
import { GroupDisplay } from "./GroupDisplay";
|
||||
import { FiAlertCircle, FiChevronLeft } from "react-icons/fi";
|
||||
import { useSpecificUserGroup } from "./hook";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { useConnectorCredentialIndexingStatus, useUsers } from "@/lib/hooks";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const Page = ({ params }: { params: { groupId: string } }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
userGroup,
|
||||
isLoading: userGroupIsLoading,
|
||||
error: userGroupError,
|
||||
refreshUserGroup,
|
||||
} = useSpecificUserGroup(params.groupId);
|
||||
const {
|
||||
data: users,
|
||||
isLoading: userIsLoading,
|
||||
error: usersError,
|
||||
} = useUsers();
|
||||
const {
|
||||
data: ccPairs,
|
||||
isLoading: isCCPairsLoading,
|
||||
error: ccPairsError,
|
||||
} = useConnectorCredentialIndexingStatus();
|
||||
|
||||
if (userGroupIsLoading || userIsLoading || isCCPairsLoading) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="my-auto">
|
||||
<ThreeDotsLoader />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userGroup || userGroupError) {
|
||||
return <div>Error loading user group</div>;
|
||||
}
|
||||
if (!users || usersError) {
|
||||
return <div>Error loading users</div>;
|
||||
}
|
||||
if (!ccPairs || ccPairsError) {
|
||||
return <div>Error loading connectors</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
<div
|
||||
className="my-auto flex mb-1 hover:bg-gray-700 w-fit pr-2 cursor-pointer rounded-lg"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<FiChevronLeft className="mr-1 my-auto" />
|
||||
Back
|
||||
</div>
|
||||
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
|
||||
<GroupsIcon size={32} />
|
||||
<h1 className="text-3xl font-bold pl-2">
|
||||
{userGroup ? userGroup.name : <FiAlertCircle />}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{userGroup ? (
|
||||
<GroupDisplay
|
||||
users={users}
|
||||
ccPairs={ccPairs}
|
||||
userGroup={userGroup}
|
||||
refreshUserGroup={refreshUserGroup}
|
||||
/>
|
||||
) : (
|
||||
<div>Unable to fetch User Group :(</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
14
web/src/app/ee/admin/groups/hooks.ts
Normal file
14
web/src/app/ee/admin/groups/hooks.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { UserGroup } from "./types";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
|
||||
const USER_GROUP_URL = "/api/manage/admin/user-group";
|
||||
|
||||
export const useUserGroups = () => {
|
||||
const swrResponse = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher);
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
refreshUserGroups: () => mutate(USER_GROUP_URL),
|
||||
};
|
||||
};
|
17
web/src/app/ee/admin/groups/lib.ts
Normal file
17
web/src/app/ee/admin/groups/lib.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { UserGroupCreation } from "./types";
|
||||
|
||||
export const createUserGroup = async (userGroup: UserGroupCreation) => {
|
||||
return fetch("/api/manage/admin/user-group", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(userGroup),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteUserGroup = async (userGroupId: number) => {
|
||||
return fetch(`/api/manage/admin/user-group/${userGroupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
86
web/src/app/ee/admin/groups/page.tsx
Normal file
86
web/src/app/ee/admin/groups/page.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
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 { Button } from "@/components/Button";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { useConnectorCredentialIndexingStatus, useUsers } from "@/lib/hooks";
|
||||
import { useUserGroups } from "./hooks";
|
||||
|
||||
const Main = () => {
|
||||
const { popup, setPopup } = usePopup();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
|
||||
|
||||
const {
|
||||
data: ccPairs,
|
||||
isLoading: isCCPairsLoading,
|
||||
error: ccPairsError,
|
||||
} = useConnectorCredentialIndexingStatus();
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isLoading: userIsLoading,
|
||||
error: usersError,
|
||||
} = useUsers();
|
||||
|
||||
if (isLoading || isCCPairsLoading || userIsLoading) {
|
||||
return <ThreeDotsLoader />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <div className="text-red-600">Error loading users</div>;
|
||||
}
|
||||
|
||||
if (ccPairsError || !ccPairs) {
|
||||
return <div className="text-red-600">Error loading connectors</div>;
|
||||
}
|
||||
|
||||
if (usersError || !users) {
|
||||
return <div className="text-red-600">Error loading users</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
<div className="my-3">
|
||||
<Button onClick={() => setShowForm(true)}>Create New User Group</Button>
|
||||
</div>
|
||||
<UserGroupsTable
|
||||
userGroups={data}
|
||||
setPopup={setPopup}
|
||||
refresh={refreshUserGroups}
|
||||
/>
|
||||
{showForm && (
|
||||
<UserGroupCreationForm
|
||||
onClose={() => {
|
||||
refreshUserGroups();
|
||||
setShowForm(false);
|
||||
}}
|
||||
setPopup={setPopup}
|
||||
users={users}
|
||||
ccPairs={ccPairs}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="mx-auto container">
|
||||
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
|
||||
<GroupsIcon size={32} />
|
||||
<h1 className="text-3xl font-bold pl-2">Manage Users Groups</h1>
|
||||
</div>
|
||||
|
||||
<Main />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
21
web/src/app/ee/admin/groups/types.ts
Normal file
21
web/src/app/ee/admin/groups/types.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { CCPairDescriptor, User } from "@/lib/types";
|
||||
|
||||
export interface UserGroupUpdate {
|
||||
user_ids: string[];
|
||||
cc_pair_ids: number[];
|
||||
}
|
||||
|
||||
export interface UserGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
users: User[];
|
||||
cc_pairs: CCPairDescriptor<any, any>[];
|
||||
is_up_to_date: boolean;
|
||||
is_up_for_deletion: boolean;
|
||||
}
|
||||
|
||||
export interface UserGroupCreation {
|
||||
name: string;
|
||||
user_ids: string[];
|
||||
cc_pair_ids: number[];
|
||||
}
|
11
web/src/app/ee/admin/layout.tsx
Normal file
11
web/src/app/ee/admin/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
/* Duplicate of `/app/admin/layout.tsx */
|
||||
|
||||
import { Layout } from "@/components/admin/Layout";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return await Layout({ children });
|
||||
}
|
19
web/src/app/ee/layout.tsx
Normal file
19
web/src/app/ee/layout.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { EE_ENABLED } from "@/lib/constants";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!EE_ENABLED) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="mx-auto my-auto text-lg font-bold text-red-500">
|
||||
This funcitonality is only available in the Enterprise Edition :(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
@ -8,6 +8,7 @@ import {
|
||||
ZoomInIcon,
|
||||
RobotIcon,
|
||||
ConnectorIcon,
|
||||
GroupsIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import { User } from "@/lib/types";
|
||||
import {
|
||||
@ -15,6 +16,7 @@ import {
|
||||
getAuthTypeMetadataSS,
|
||||
getCurrentUserSS,
|
||||
} from "@/lib/userSS";
|
||||
import { EE_ENABLED } from "@/lib/constants";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FiCpu, FiPackage, FiSettings, FiSlack, FiTool } from "react-icons/fi";
|
||||
|
||||
@ -179,6 +181,19 @@ export async function Layout({ children }: { children: React.ReactNode }) {
|
||||
),
|
||||
link: "/admin/users",
|
||||
},
|
||||
...(EE_ENABLED
|
||||
? [
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<GroupsIcon size={18} />
|
||||
<div className="ml-1">Groups</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/groups",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -10,10 +10,11 @@ import {
|
||||
} from "@/lib/types";
|
||||
import { deleteConnectorIfExistsAndIsUnlinked } from "@/lib/connector";
|
||||
import { FormBodyBuilder, RequireAtLeastOne } from "./types";
|
||||
import { TextFormField } from "./Field";
|
||||
import { BooleanFormField, TextFormField } from "./Field";
|
||||
import { createCredential, linkCredential } from "@/lib/credential";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { Button } from "@tremor/react";
|
||||
import { EE_ENABLED } from "@/lib/constants";
|
||||
|
||||
const BASE_CONNECTOR_URL = "/api/manage/admin/connector";
|
||||
|
||||
@ -74,6 +75,7 @@ interface BaseProps<T extends Yup.AnyObject> {
|
||||
// If specified, then we will create an empty credential and associate
|
||||
// the connector with it. If credentialId is specified, then this will be ignored
|
||||
shouldCreateEmptyCredentialForConnector?: boolean;
|
||||
showNonPublicOption?: boolean;
|
||||
}
|
||||
|
||||
type ConnectorFormProps<T extends Yup.AnyObject> = RequireAtLeastOne<
|
||||
@ -95,26 +97,44 @@ export function ConnectorForm<T extends Yup.AnyObject>({
|
||||
pruneFreq,
|
||||
onSubmit,
|
||||
shouldCreateEmptyCredentialForConnector,
|
||||
// only show this option for EE, since groups are not supported in CE
|
||||
showNonPublicOption = EE_ENABLED,
|
||||
}: ConnectorFormProps<T>): JSX.Element {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
const shouldHaveNameInput = credentialId !== undefined && !ccPairNameBuilder;
|
||||
|
||||
const ccPairNameInitialValue = shouldHaveNameInput
|
||||
? { cc_pair_name: "" }
|
||||
: {};
|
||||
const publicOptionInitialValue = showNonPublicOption
|
||||
? { is_public: false }
|
||||
: {};
|
||||
|
||||
let finalValidationSchema =
|
||||
validationSchema as Yup.ObjectSchema<Yup.AnyObject>;
|
||||
if (shouldHaveNameInput) {
|
||||
finalValidationSchema = finalValidationSchema.concat(CCPairNameHaver);
|
||||
}
|
||||
if (showNonPublicOption) {
|
||||
finalValidationSchema = finalValidationSchema.concat(
|
||||
Yup.object().shape({
|
||||
is_public: Yup.boolean(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
<Formik
|
||||
initialValues={
|
||||
shouldHaveNameInput
|
||||
? { cc_pair_name: "", ...initialValues }
|
||||
: initialValues
|
||||
}
|
||||
validationSchema={
|
||||
shouldHaveNameInput
|
||||
? validationSchema.concat(CCPairNameHaver)
|
||||
: validationSchema
|
||||
}
|
||||
initialValues={{
|
||||
...publicOptionInitialValue,
|
||||
...ccPairNameInitialValue,
|
||||
...initialValues,
|
||||
}}
|
||||
validationSchema={finalValidationSchema}
|
||||
onSubmit={async (values, formikHelpers) => {
|
||||
formikHelpers.setSubmitting(true);
|
||||
const connectorName = nameBuilder(values);
|
||||
@ -185,7 +205,8 @@ export function ConnectorForm<T extends Yup.AnyObject>({
|
||||
const linkCredentialResponse = await linkCredential(
|
||||
response.id,
|
||||
credentialIdToLinkTo,
|
||||
ccPairName
|
||||
ccPairName as string,
|
||||
values.is_public
|
||||
);
|
||||
if (!linkCredentialResponse.ok) {
|
||||
const linkCredentialErrorMsg =
|
||||
@ -222,6 +243,22 @@ export function ConnectorForm<T extends Yup.AnyObject>({
|
||||
)}
|
||||
{formBody && formBody}
|
||||
{formBodyBuilder && formBodyBuilder(values)}
|
||||
{showNonPublicOption && (
|
||||
<>
|
||||
<div className="border-t border-gray-600 py-2" />
|
||||
<BooleanFormField
|
||||
name="is_public"
|
||||
label="Documents are Public?"
|
||||
subtext={
|
||||
"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"
|
||||
}
|
||||
/>
|
||||
<div className="border-t border-gray-600 py-2" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex">
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { ConnectorIndexingStatus, Credential } from "@/lib/types";
|
||||
import { BasicTable } from "@/components/admin/connectors/BasicTable";
|
||||
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useState } from "react";
|
||||
import { LinkBreakIcon, LinkIcon } from "@/components/icons/icons";
|
||||
@ -14,6 +13,7 @@ import {
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@tremor/react";
|
||||
import { FiCheck, FiXCircle } from "react-icons/fi";
|
||||
|
||||
interface StatusRowProps<ConnectorConfigType, ConnectorCredentialType> {
|
||||
connectorIndexingStatus: ConnectorIndexingStatus<
|
||||
@ -141,6 +141,10 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
|
||||
header: "Status",
|
||||
key: "status",
|
||||
},
|
||||
{
|
||||
header: "Is Public",
|
||||
key: "is_public",
|
||||
},
|
||||
];
|
||||
if (connectorIncludesCredential) {
|
||||
columns.push({
|
||||
@ -165,6 +169,7 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
|
||||
<TableHeaderCell key={header}>{header}</TableHeaderCell>
|
||||
))}
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Is Public</TableHeaderCell>
|
||||
{connectorIncludesCredential && (
|
||||
<TableHeaderCell>Credential</TableHeaderCell>
|
||||
)}
|
||||
@ -217,6 +222,13 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{connectorIndexingStatus.public_doc ? (
|
||||
<FiCheck className="my-auto text-success" size="18" />
|
||||
) : (
|
||||
<FiXCircle className="my-auto text-error" />
|
||||
)}
|
||||
</TableCell>
|
||||
{connectorIncludesCredential && (
|
||||
<TableCell>{credentialDisplay}</TableCell>
|
||||
)}
|
||||
|
@ -11,7 +11,6 @@ import {
|
||||
Brain,
|
||||
X,
|
||||
Question,
|
||||
Users,
|
||||
Gear,
|
||||
ArrowSquareOut,
|
||||
} from "@phosphor-icons/react";
|
||||
@ -36,6 +35,7 @@ import {
|
||||
FiCpu,
|
||||
FiInfo,
|
||||
FiUploadCloud,
|
||||
FiUser,
|
||||
FiUsers,
|
||||
} from "react-icons/fi";
|
||||
import { SiBookstack } from "react-icons/si";
|
||||
@ -94,7 +94,7 @@ export const UsersIcon = ({
|
||||
size = 16,
|
||||
className = defaultTailwindCSS,
|
||||
}: IconProps) => {
|
||||
return <Users size={size} className={className} />;
|
||||
return <FiUser size={size} className={className} />;
|
||||
};
|
||||
|
||||
export const GroupsIcon = ({
|
||||
|
@ -31,7 +31,8 @@ export async function deleteCredential<T>(credentialId: number) {
|
||||
export function linkCredential(
|
||||
connectorId: number,
|
||||
credentialId: number,
|
||||
name?: string
|
||||
name?: string,
|
||||
isPublic?: boolean
|
||||
) {
|
||||
return fetch(
|
||||
`/api/manage/connector/${connectorId}/credential/${credentialId}`,
|
||||
@ -40,7 +41,10 @@ export function linkCredential(
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: name || null }),
|
||||
body: JSON.stringify({
|
||||
name: name || null,
|
||||
is_public: isPublic !== undefined ? isPublic : true, // default to public
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user