mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-22 18:25:45 +00:00
Compare commits
14 Commits
more-conf-
...
borders
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56fd40e606 | ||
|
|
415d644200 | ||
|
|
30dfe6dcb4 | ||
|
|
dc5d5dfe05 | ||
|
|
0746e0be5b | ||
|
|
970320bd49 | ||
|
|
4a7bd5578e | ||
|
|
874b098a4b | ||
|
|
ce18b63eea | ||
|
|
7a919c3589 | ||
|
|
631bac4432 | ||
|
|
53428f6e9c | ||
|
|
53b3dcbace | ||
|
|
7a3c06c2d2 |
60
README.md
60
README.md
@@ -1,48 +1,48 @@
|
||||
<!-- DANSWER_METADATA={"link": "https://github.com/danswer-ai/danswer/blob/main/README.md"} -->
|
||||
<!-- DANSWER_METADATA={"link": "https://github.com/onyx-dot-app/onyx/blob/main/README.md"} -->
|
||||
<a name="readme-top"></a>
|
||||
|
||||
<h2 align="center">
|
||||
<a href="https://www.danswer.ai/"> <img width="50%" src="https://github.com/danswer-owners/danswer/blob/1fabd9372d66cd54238847197c33f091a724803b/DanswerWithName.png?raw=true)" /></a>
|
||||
<a href="https://www.onyx.app/"> <img width="50%" src="https://github.com/onyx-dot-app/onyx/blob/logo/LogoOnyx.png?raw=true)" /></a>
|
||||
</h2>
|
||||
|
||||
<p align="center">
|
||||
<p align="center">Open Source Gen-AI Chat + Unified Search.</p>
|
||||
<p align="center">Open Source Gen-AI + Enterprise Search.</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.danswer.dev/" target="_blank">
|
||||
<a href="https://docs.onyx.app/" target="_blank">
|
||||
<img src="https://img.shields.io/badge/docs-view-blue" alt="Documentation">
|
||||
</a>
|
||||
<a href="https://join.slack.com/t/danswer/shared_invite/zt-2twesxdr6-5iQitKZQpgq~hYIZ~dv3KA" target="_blank">
|
||||
<a href="https://join.slack.com/t/onyx-dot-app/shared_invite/zt-2sslpdbyq-iIbTaNIVPBw_i_4vrujLYQ" target="_blank">
|
||||
<img src="https://img.shields.io/badge/slack-join-blue.svg?logo=slack" alt="Slack">
|
||||
</a>
|
||||
<a href="https://discord.gg/TDJ59cGV2X" target="_blank">
|
||||
<img src="https://img.shields.io/badge/discord-join-blue.svg?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
<a href="https://github.com/danswer-ai/danswer/blob/main/README.md" target="_blank">
|
||||
<a href="https://github.com/onyx-dot-app/onyx/blob/main/README.md" target="_blank">
|
||||
<img src="https://img.shields.io/static/v1?label=license&message=MIT&color=blue" alt="License">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<strong>[Danswer](https://www.danswer.ai/)</strong> is the AI Assistant connected to your company's docs, apps, and people.
|
||||
Danswer provides a Chat interface and plugs into any LLM of your choice. Danswer can be deployed anywhere and for any
|
||||
<strong>[Onyx](https://www.onyx.app/)</strong> (Formerly Danswer) is the AI Assistant connected to your company's docs, apps, and people.
|
||||
Onyx provides a Chat interface and plugs into any LLM of your choice. Onyx can be deployed anywhere and for any
|
||||
scale - on a laptop, on-premise, or to cloud. Since you own the deployment, your user data and chats are fully in your
|
||||
own control. Danswer is MIT licensed and designed to be modular and easily extensible. The system also comes fully ready
|
||||
own control. Onyx is dual Licensed with most of it under MIT license and designed to be modular and easily extensible. The system also comes fully ready
|
||||
for production usage with user authentication, role management (admin/basic users), chat persistence, and a UI for
|
||||
configuring Personas (AI Assistants) and their Prompts.
|
||||
configuring AI Assistants.
|
||||
|
||||
Danswer also serves as a Unified Search across all common workplace tools such as Slack, Google Drive, Confluence, etc.
|
||||
By combining LLMs and team specific knowledge, Danswer becomes a subject matter expert for the team. Imagine ChatGPT if
|
||||
Onyx also serves as a Enterprise Search across all common workplace tools such as Slack, Google Drive, Confluence, etc.
|
||||
By combining LLMs and team specific knowledge, Onyx becomes a subject matter expert for the team. Imagine ChatGPT if
|
||||
it had access to your team's unique knowledge! It enables questions such as "A customer wants feature X, is this already
|
||||
supported?" or "Where's the pull request for feature Y?"
|
||||
|
||||
<h3>Usage</h3>
|
||||
|
||||
Danswer Web App:
|
||||
Onyx Web App:
|
||||
|
||||
https://github.com/danswer-ai/danswer/assets/32520769/563be14c-9304-47b5-bf0a-9049c2b6f410
|
||||
|
||||
|
||||
Or, plug Danswer into your existing Slack workflows (more integrations to come 😁):
|
||||
Or, plug Onyx into your existing Slack workflows (more integrations to come 😁):
|
||||
|
||||
https://github.com/danswer-ai/danswer/assets/25087905/3e19739b-d178-4371-9a38-011430bdec1b
|
||||
|
||||
@@ -52,16 +52,16 @@ For more details on the Admin UI to manage connectors and users, check out our
|
||||
|
||||
## Deployment
|
||||
|
||||
Danswer can easily be run locally (even on a laptop) or deployed on a virtual machine with a single
|
||||
`docker compose` command. Checkout our [docs](https://docs.danswer.dev/quickstart) to learn more.
|
||||
Onyx can easily be run locally (even on a laptop) or deployed on a virtual machine with a single
|
||||
`docker compose` command. Checkout our [docs](https://docs.onyx.app/quickstart) to learn more.
|
||||
|
||||
We also have built-in support for deployment on Kubernetes. Files for that can be found [here](https://github.com/danswer-ai/danswer/tree/main/deployment/kubernetes).
|
||||
We also have built-in support for deployment on Kubernetes. Files for that can be found [here](https://github.com/onyx-dot-app/onyx/tree/main/deployment/kubernetes).
|
||||
|
||||
|
||||
## 💃 Main Features
|
||||
* Chat UI with the ability to select documents to chat with.
|
||||
* Create custom AI Assistants with different prompts and backing knowledge sets.
|
||||
* Connect Danswer with LLM of your choice (self-host for a fully airgapped solution).
|
||||
* Connect Onyx with LLM of your choice (self-host for a fully airgapped solution).
|
||||
* Document Search + AI Answers for natural language queries.
|
||||
* Connectors to all common workplace tools like Google Drive, Confluence, Slack, etc.
|
||||
* Slack integration to get answers and search results directly in Slack.
|
||||
@@ -75,12 +75,12 @@ We also have built-in support for deployment on Kubernetes. Files for that can b
|
||||
* Organizational understanding and ability to locate and suggest experts from your team.
|
||||
|
||||
|
||||
## Other Notable Benefits of Danswer
|
||||
## Other Notable Benefits of Onyx
|
||||
* User Authentication with document level access management.
|
||||
* Best in class Hybrid Search across all sources (BM-25 + prefix aware embedding models).
|
||||
* Admin Dashboard to configure connectors, document-sets, access, etc.
|
||||
* Custom deep learning models + learn from user feedback.
|
||||
* Easy deployment and ability to host Danswer anywhere of your choosing.
|
||||
* Easy deployment and ability to host Onyx anywhere of your choosing.
|
||||
|
||||
|
||||
## 🔌 Connectors
|
||||
@@ -108,10 +108,10 @@ Efficiently pulls the latest changes from:
|
||||
|
||||
## 📚 Editions
|
||||
|
||||
There are two editions of Danswer:
|
||||
There are two editions of Onyx:
|
||||
|
||||
* Danswer Community Edition (CE) is available freely under the MIT Expat license. This version has ALL the core features discussed above. This is the version of Danswer you will get if you follow the Deployment guide above.
|
||||
* Danswer Enterprise Edition (EE) includes extra features that are primarily useful for larger organizations. Specifically, this includes:
|
||||
* Onyx Community Edition (CE) is available freely under the MIT Expat license. This version has ALL the core features discussed above. This is the version of Onyx you will get if you follow the Deployment guide above.
|
||||
* Onyx Enterprise Edition (EE) includes extra features that are primarily useful for larger organizations. Specifically, this includes:
|
||||
* Single Sign-On (SSO), with support for both SAML and OIDC
|
||||
* Role-based access control
|
||||
* Document permission inheritance from connected sources
|
||||
@@ -119,24 +119,24 @@ There are two editions of Danswer:
|
||||
* Whitelabeling
|
||||
* API key authentication
|
||||
* Encryption of secrets
|
||||
* Any many more! Checkout [our website](https://www.danswer.ai/) for the latest.
|
||||
* Any many more! Checkout [our website](https://www.onyx.app/) for the latest.
|
||||
|
||||
To try the Danswer Enterprise Edition:
|
||||
To try the Onyx Enterprise Edition:
|
||||
|
||||
1. Checkout our [Cloud product](https://app.danswer.ai/signup).
|
||||
2. For self-hosting, contact us at [founders@danswer.ai](mailto:founders@danswer.ai) or book a call with us on our [Cal](https://cal.com/team/danswer/founders).
|
||||
1. Checkout our [Cloud product](https://cloud.onyx.app/signup).
|
||||
2. For self-hosting, contact us at [founders@onyx.app](mailto:founders@onyx.app) or book a call with us on our [Cal](https://cal.com/team/danswer/founders).
|
||||
|
||||
## 💡 Contributing
|
||||
Looking to contribute? Please check out the [Contribution Guide](CONTRIBUTING.md) for more details.
|
||||
|
||||
## ⭐Star History
|
||||
|
||||
[](https://star-history.com/#danswer-ai/danswer&Date)
|
||||
[](https://star-history.com/#onyx-dot-app/onyx&Date)
|
||||
|
||||
## ✨Contributors
|
||||
|
||||
<a href="https://github.com/danswer-ai/danswer/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=danswer-ai/danswer"/>
|
||||
<a href="https://github.com/onyx-dot-app/onyx/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=onyx-dot-app/onyx"/>
|
||||
</a>
|
||||
|
||||
<p align="right" style="font-size: 14px; color: #555; margin-top: 20px;">
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""non-nullbale slack bot id in channel config
|
||||
|
||||
Revision ID: f7a894b06d02
|
||||
Revises: 9f696734098f
|
||||
Create Date: 2024-12-06 12:55:42.845723
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f7a894b06d02"
|
||||
down_revision = "9f696734098f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Delete all rows with null slack_bot_id
|
||||
op.execute("DELETE FROM slack_channel_config WHERE slack_bot_id IS NULL")
|
||||
|
||||
# Make slack_bot_id non-nullable
|
||||
op.alter_column(
|
||||
"slack_channel_config",
|
||||
"slack_bot_id",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Make slack_bot_id nullable again
|
||||
op.alter_column(
|
||||
"slack_channel_config",
|
||||
"slack_bot_id",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
)
|
||||
@@ -219,7 +219,7 @@ def connector_permission_sync_generator_task(
|
||||
|
||||
r = get_redis_client(tenant_id=tenant_id)
|
||||
|
||||
lock = r.lock(
|
||||
lock: RedisLock = r.lock(
|
||||
DanswerRedisLocks.CONNECTOR_DOC_PERMISSIONS_SYNC_LOCK_PREFIX
|
||||
+ f"_{redis_connector.id}",
|
||||
timeout=CELERY_PERMISSIONS_SYNC_LOCK_TIMEOUT,
|
||||
|
||||
@@ -640,12 +640,16 @@ def connector_indexing_proxy_task(
|
||||
continue
|
||||
|
||||
if job.status == "error":
|
||||
exit_code: int | None = None
|
||||
if job.process:
|
||||
exit_code = job.process.exitcode
|
||||
task_logger.error(
|
||||
"Indexing watchdog - spawned task exceptioned: "
|
||||
f"attempt={index_attempt_id} "
|
||||
f"tenant={tenant_id} "
|
||||
f"cc_pair={cc_pair_id} "
|
||||
f"search_settings={search_settings_id} "
|
||||
f"exit_code={exit_code} "
|
||||
f"error={job.exception()}"
|
||||
)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class SimpleJob:
|
||||
return "running"
|
||||
elif self.process.exitcode is None:
|
||||
return "cancelled"
|
||||
elif self.process.exitcode > 0:
|
||||
elif self.process.exitcode != 0:
|
||||
return "error"
|
||||
else:
|
||||
return "finished"
|
||||
@@ -123,7 +123,8 @@ class SimpleJobClient:
|
||||
self._cleanup_completed_jobs()
|
||||
if len(self.jobs) >= self.n_workers:
|
||||
logger.debug(
|
||||
f"No available workers to run job. Currently running '{len(self.jobs)}' jobs, with a limit of '{self.n_workers}'."
|
||||
f"No available workers to run job. "
|
||||
f"Currently running '{len(self.jobs)}' jobs, with a limit of '{self.n_workers}'."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@ OAUTH_CLIENT_SECRET = (
|
||||
or ""
|
||||
)
|
||||
|
||||
# for future OAuth connector support
|
||||
# OAUTH_CONFLUENCE_CLIENT_ID = os.environ.get("OAUTH_CONFLUENCE_CLIENT_ID", "")
|
||||
# OAUTH_CONFLUENCE_CLIENT_SECRET = os.environ.get("OAUTH_CONFLUENCE_CLIENT_SECRET", "")
|
||||
# OAUTH_JIRA_CLIENT_ID = os.environ.get("OAUTH_JIRA_CLIENT_ID", "")
|
||||
# OAUTH_JIRA_CLIENT_SECRET = os.environ.get("OAUTH_JIRA_CLIENT_SECRET", "")
|
||||
|
||||
USER_AUTH_SECRET = os.environ.get("USER_AUTH_SECRET", "")
|
||||
|
||||
# for basic auth
|
||||
|
||||
@@ -368,4 +368,5 @@ def build_confluence_client(
|
||||
backoff_and_retry=True,
|
||||
max_backoff_retries=10,
|
||||
max_backoff_seconds=60,
|
||||
cloud=is_cloud,
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ def get_created_datetime(chat_message: ChatMessage) -> datetime:
|
||||
|
||||
def _extract_channel_members(channel: Channel) -> list[BasicExpertInfo]:
|
||||
channel_members_list: list[BasicExpertInfo] = []
|
||||
members = channel.members.get().execute_query()
|
||||
members = channel.members.get().execute_query_retry()
|
||||
for member in members:
|
||||
channel_members_list.append(BasicExpertInfo(display_name=member.display_name))
|
||||
return channel_members_list
|
||||
@@ -51,7 +51,7 @@ def _get_threads_from_channel(
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
|
||||
query = channel.messages.get()
|
||||
base_messages: list[ChatMessage] = query.execute_query()
|
||||
base_messages: list[ChatMessage] = query.execute_query_retry()
|
||||
|
||||
threads: list[list[ChatMessage]] = []
|
||||
for base_message in base_messages:
|
||||
@@ -65,7 +65,7 @@ def _get_threads_from_channel(
|
||||
continue
|
||||
|
||||
reply_query = base_message.replies.get_all()
|
||||
replies = reply_query.execute_query()
|
||||
replies = reply_query.execute_query_retry()
|
||||
|
||||
# start a list containing the base message and its replies
|
||||
thread: list[ChatMessage] = [base_message]
|
||||
@@ -82,7 +82,7 @@ def _get_channels_from_teams(
|
||||
channels_list: list[Channel] = []
|
||||
for team in teams:
|
||||
query = team.channels.get()
|
||||
channels = query.execute_query()
|
||||
channels = query.execute_query_retry()
|
||||
channels_list.extend(channels)
|
||||
|
||||
return channels_list
|
||||
@@ -210,7 +210,7 @@ class TeamsConnector(LoadConnector, PollConnector):
|
||||
|
||||
teams_list: list[Team] = []
|
||||
|
||||
teams = self.graph_client.teams.get().execute_query()
|
||||
teams = self.graph_client.teams.get().execute_query_retry()
|
||||
|
||||
if len(self.requested_team_list) > 0:
|
||||
adjusted_request_strings = [
|
||||
@@ -234,14 +234,25 @@ class TeamsConnector(LoadConnector, PollConnector):
|
||||
raise ConnectorMissingCredentialError("Teams")
|
||||
|
||||
teams = self._get_all_teams()
|
||||
logger.debug(f"Found available teams: {[str(t) for t in teams]}")
|
||||
if not teams:
|
||||
msg = "No teams found."
|
||||
logger.error(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
channels = _get_channels_from_teams(
|
||||
teams=teams,
|
||||
)
|
||||
logger.debug(f"Found available channels: {[c.id for c in channels]}")
|
||||
if not channels:
|
||||
msg = "No channels found."
|
||||
logger.error(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
# goes over channels, converts them into Document objects and then yields them in batches
|
||||
doc_batch: list[Document] = []
|
||||
for channel in channels:
|
||||
logger.debug(f"Fetching threads from channel: {channel.id}")
|
||||
thread_list = _get_threads_from_channel(channel, start=start, end=end)
|
||||
for thread in thread_list:
|
||||
converted_doc = _convert_thread_to_document(channel, thread)
|
||||
@@ -259,8 +270,8 @@ class TeamsConnector(LoadConnector, PollConnector):
|
||||
def poll_source(
|
||||
self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
|
||||
) -> GenerateDocumentsOutput:
|
||||
start_datetime = datetime.utcfromtimestamp(start)
|
||||
end_datetime = datetime.utcfromtimestamp(end)
|
||||
start_datetime = datetime.fromtimestamp(start, timezone.utc)
|
||||
end_datetime = datetime.fromtimestamp(end, timezone.utc)
|
||||
return self._fetch_from_teams(start=start_datetime, end=end_datetime)
|
||||
|
||||
|
||||
|
||||
@@ -248,7 +248,6 @@ def create_credential(
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
return credential
|
||||
|
||||
|
||||
|
||||
@@ -1490,7 +1490,9 @@ class SlackChannelConfig(Base):
|
||||
__tablename__ = "slack_channel_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slack_bot_id: Mapped[int] = mapped_column(ForeignKey("slack_bot.id"), nullable=True)
|
||||
slack_bot_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("slack_bot.id"), nullable=False
|
||||
)
|
||||
persona_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("persona.id"), nullable=True
|
||||
)
|
||||
|
||||
@@ -453,9 +453,9 @@ def upsert_persona(
|
||||
"""
|
||||
|
||||
if persona_id is not None:
|
||||
persona = db_session.query(Persona).filter_by(id=persona_id).first()
|
||||
existing_persona = db_session.query(Persona).filter_by(id=persona_id).first()
|
||||
else:
|
||||
persona = _get_persona_by_name(
|
||||
existing_persona = _get_persona_by_name(
|
||||
persona_name=name, user=user, db_session=db_session
|
||||
)
|
||||
|
||||
@@ -481,62 +481,78 @@ def upsert_persona(
|
||||
prompts = None
|
||||
if prompt_ids is not None:
|
||||
prompts = db_session.query(Prompt).filter(Prompt.id.in_(prompt_ids)).all()
|
||||
if not prompts and prompt_ids:
|
||||
raise ValueError("prompts not found")
|
||||
|
||||
if prompts is not None and len(prompts) == 0:
|
||||
raise ValueError(
|
||||
f"Invalid Persona config, no valid prompts "
|
||||
f"specified. Specified IDs were: '{prompt_ids}'"
|
||||
)
|
||||
|
||||
# ensure all specified tools are valid
|
||||
if tools:
|
||||
validate_persona_tools(tools)
|
||||
|
||||
if persona:
|
||||
if existing_persona:
|
||||
# Built-in personas can only be updated through YAML configuration.
|
||||
# This ensures that core system personas are not modified unintentionally.
|
||||
if persona.builtin_persona and not builtin_persona:
|
||||
if existing_persona.builtin_persona and not builtin_persona:
|
||||
raise ValueError("Cannot update builtin persona with non-builtin.")
|
||||
|
||||
# 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
|
||||
# will raise an Exception if the user does not have permission
|
||||
existing_persona = fetch_persona_by_id(
|
||||
db_session=db_session,
|
||||
persona_id=existing_persona.id,
|
||||
user=user,
|
||||
get_editable=True,
|
||||
)
|
||||
|
||||
# The following update excludes `default`, `built-in`, and display priority.
|
||||
# Display priority is handled separately in the `display-priority` endpoint.
|
||||
# `default` and `built-in` properties can only be set when creating a persona.
|
||||
persona.name = name
|
||||
persona.description = description
|
||||
persona.num_chunks = num_chunks
|
||||
persona.chunks_above = chunks_above
|
||||
persona.chunks_below = chunks_below
|
||||
persona.llm_relevance_filter = llm_relevance_filter
|
||||
persona.llm_filter_extraction = llm_filter_extraction
|
||||
persona.recency_bias = recency_bias
|
||||
persona.llm_model_provider_override = llm_model_provider_override
|
||||
persona.llm_model_version_override = llm_model_version_override
|
||||
persona.starter_messages = starter_messages
|
||||
persona.deleted = False # Un-delete if previously deleted
|
||||
persona.is_public = is_public
|
||||
persona.icon_color = icon_color
|
||||
persona.icon_shape = icon_shape
|
||||
existing_persona.name = name
|
||||
existing_persona.description = description
|
||||
existing_persona.num_chunks = num_chunks
|
||||
existing_persona.chunks_above = chunks_above
|
||||
existing_persona.chunks_below = chunks_below
|
||||
existing_persona.llm_relevance_filter = llm_relevance_filter
|
||||
existing_persona.llm_filter_extraction = llm_filter_extraction
|
||||
existing_persona.recency_bias = recency_bias
|
||||
existing_persona.llm_model_provider_override = llm_model_provider_override
|
||||
existing_persona.llm_model_version_override = llm_model_version_override
|
||||
existing_persona.starter_messages = starter_messages
|
||||
existing_persona.deleted = False # Un-delete if previously deleted
|
||||
existing_persona.is_public = is_public
|
||||
existing_persona.icon_color = icon_color
|
||||
existing_persona.icon_shape = icon_shape
|
||||
if remove_image or uploaded_image_id:
|
||||
persona.uploaded_image_id = uploaded_image_id
|
||||
persona.is_visible = is_visible
|
||||
persona.search_start_date = search_start_date
|
||||
persona.category_id = category_id
|
||||
existing_persona.uploaded_image_id = uploaded_image_id
|
||||
existing_persona.is_visible = is_visible
|
||||
existing_persona.search_start_date = search_start_date
|
||||
existing_persona.category_id = category_id
|
||||
# Do not delete any associations manually added unless
|
||||
# a new updated list is provided
|
||||
if document_sets is not None:
|
||||
persona.document_sets.clear()
|
||||
persona.document_sets = document_sets or []
|
||||
existing_persona.document_sets.clear()
|
||||
existing_persona.document_sets = document_sets or []
|
||||
|
||||
if prompts is not None:
|
||||
persona.prompts.clear()
|
||||
persona.prompts = prompts or []
|
||||
existing_persona.prompts.clear()
|
||||
existing_persona.prompts = prompts
|
||||
|
||||
if tools is not None:
|
||||
persona.tools = tools or []
|
||||
existing_persona.tools = tools or []
|
||||
|
||||
persona = existing_persona
|
||||
|
||||
else:
|
||||
persona = Persona(
|
||||
if not prompts:
|
||||
raise ValueError(
|
||||
"Invalid Persona config. "
|
||||
"Must specify at least one prompt for a new persona."
|
||||
)
|
||||
|
||||
new_persona = Persona(
|
||||
id=persona_id,
|
||||
user_id=user.id if user else None,
|
||||
is_public=is_public,
|
||||
@@ -549,7 +565,7 @@ def upsert_persona(
|
||||
llm_filter_extraction=llm_filter_extraction,
|
||||
recency_bias=recency_bias,
|
||||
builtin_persona=builtin_persona,
|
||||
prompts=prompts or [],
|
||||
prompts=prompts,
|
||||
document_sets=document_sets or [],
|
||||
llm_model_provider_override=llm_model_provider_override,
|
||||
llm_model_version_override=llm_model_version_override,
|
||||
@@ -564,8 +580,8 @@ def upsert_persona(
|
||||
is_default_persona=is_default_persona,
|
||||
category_id=category_id,
|
||||
)
|
||||
db_session.add(persona)
|
||||
|
||||
db_session.add(new_persona)
|
||||
persona = new_persona
|
||||
if commit:
|
||||
db_session.commit()
|
||||
else:
|
||||
|
||||
@@ -268,12 +268,16 @@ class DefaultMultiLLM(LLM):
|
||||
|
||||
# NOTE: have to set these as environment variables for Litellm since
|
||||
# not all are able to passed in but they always support them set as env
|
||||
# variables
|
||||
# variables. We'll also try passing them in, since litellm just ignores
|
||||
# addtional kwargs (and some kwargs MUST be passed in rather than set as
|
||||
# env variables)
|
||||
if custom_config:
|
||||
for k, v in custom_config.items():
|
||||
os.environ[k] = v
|
||||
|
||||
model_kwargs = model_kwargs or {}
|
||||
if custom_config:
|
||||
model_kwargs.update(custom_config)
|
||||
if extra_headers:
|
||||
model_kwargs.update({"extra_headers": extra_headers})
|
||||
if extra_body:
|
||||
|
||||
@@ -105,7 +105,6 @@ from shared_configs.configs import CORS_ALLOWED_ORIGIN
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.configs import SENTRY_DSN
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ def load_personas_from_yaml(
|
||||
if prompts:
|
||||
prompt_ids = [prompt.id for prompt in prompts if prompt is not None]
|
||||
|
||||
if not prompt_ids:
|
||||
raise ValueError("Invalid Persona config, no prompts exist")
|
||||
|
||||
p_id = persona.get("id")
|
||||
tool_ids = []
|
||||
|
||||
@@ -123,12 +126,16 @@ def load_personas_from_yaml(
|
||||
tool_ids=tool_ids,
|
||||
builtin_persona=True,
|
||||
is_public=True,
|
||||
display_priority=existing_persona.display_priority
|
||||
if existing_persona is not None
|
||||
else persona.get("display_priority"),
|
||||
is_visible=existing_persona.is_visible
|
||||
if existing_persona is not None
|
||||
else persona.get("is_visible"),
|
||||
display_priority=(
|
||||
existing_persona.display_priority
|
||||
if existing_persona is not None
|
||||
else persona.get("display_priority")
|
||||
),
|
||||
is_visible=(
|
||||
existing_persona.is_visible
|
||||
if existing_persona is not None
|
||||
else persona.get("is_visible")
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,3 +36,6 @@ JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)
|
||||
# Super Users
|
||||
SUPER_USERS = json.loads(os.environ.get("SUPER_USERS", '["pablo@danswer.ai"]'))
|
||||
SUPER_CLOUD_API_KEY = os.environ.get("SUPER_CLOUD_API_KEY", "api_key")
|
||||
|
||||
OAUTH_SLACK_CLIENT_ID = os.environ.get("OAUTH_SLACK_CLIENT_ID", "")
|
||||
OAUTH_SLACK_CLIENT_SECRET = os.environ.get("OAUTH_SLACK_CLIENT_SECRET", "")
|
||||
|
||||
@@ -26,6 +26,7 @@ from ee.danswer.server.enterprise_settings.api import (
|
||||
)
|
||||
from ee.danswer.server.manage.standard_answer import router as standard_answer_router
|
||||
from ee.danswer.server.middleware.tenant_tracking import add_tenant_id_middleware
|
||||
from ee.danswer.server.oauth import router as oauth_router
|
||||
from ee.danswer.server.query_and_chat.chat_backend import (
|
||||
router as chat_router,
|
||||
)
|
||||
@@ -119,6 +120,8 @@ def get_application() -> FastAPI:
|
||||
include_router_with_global_prefix_prepended(application, query_router)
|
||||
include_router_with_global_prefix_prepended(application, chat_router)
|
||||
include_router_with_global_prefix_prepended(application, standard_answer_router)
|
||||
include_router_with_global_prefix_prepended(application, oauth_router)
|
||||
|
||||
# Enterprise-only global settings
|
||||
include_router_with_global_prefix_prepended(
|
||||
application, enterprise_settings_admin_router
|
||||
|
||||
423
backend/ee/danswer/server/oauth.py
Normal file
423
backend/ee/danswer/server/oauth.py
Normal file
@@ -0,0 +1,423 @@
|
||||
import base64
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.auth.users import current_user
|
||||
from danswer.configs.app_configs import WEB_DOMAIN
|
||||
from danswer.configs.constants import DocumentSource
|
||||
from danswer.db.credentials import create_credential
|
||||
from danswer.db.engine import get_current_tenant_id
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.models import User
|
||||
from danswer.redis.redis_pool import get_redis_client
|
||||
from danswer.server.documents.models import CredentialBase
|
||||
from danswer.utils.logger import setup_logger
|
||||
from ee.danswer.configs.app_configs import OAUTH_SLACK_CLIENT_ID
|
||||
from ee.danswer.configs.app_configs import OAUTH_SLACK_CLIENT_SECRET
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
router = APIRouter(prefix="/oauth")
|
||||
|
||||
|
||||
class SlackOAuth:
|
||||
# https://knock.app/blog/how-to-authenticate-users-in-slack-using-oauth
|
||||
# Example: https://api.slack.com/authentication/oauth-v2#exchanging
|
||||
|
||||
class OAuthSession(BaseModel):
|
||||
"""Stored in redis to be looked up on callback"""
|
||||
|
||||
email: str
|
||||
redirect_on_success: str | None # Where to send the user if OAuth flow succeeds
|
||||
|
||||
CLIENT_ID = OAUTH_SLACK_CLIENT_ID
|
||||
CLIENT_SECRET = OAUTH_SLACK_CLIENT_SECRET
|
||||
|
||||
TOKEN_URL = "https://slack.com/api/oauth.v2.access"
|
||||
|
||||
# SCOPE is per https://docs.danswer.dev/connectors/slack
|
||||
BOT_SCOPE = (
|
||||
"channels:history,"
|
||||
"channels:read,"
|
||||
"groups:history,"
|
||||
"groups:read,"
|
||||
"channels:join,"
|
||||
"im:history,"
|
||||
"users:read,"
|
||||
"users:read.email,"
|
||||
"usergroups:read"
|
||||
)
|
||||
|
||||
REDIRECT_URI = f"{WEB_DOMAIN}/admin/connectors/slack/oauth/callback"
|
||||
DEV_REDIRECT_URI = f"https://redirectmeto.com/{REDIRECT_URI}"
|
||||
|
||||
@classmethod
|
||||
def generate_oauth_url(cls, state: str) -> str:
|
||||
url = (
|
||||
f"https://slack.com/oauth/v2/authorize"
|
||||
f"?client_id={cls.CLIENT_ID}"
|
||||
f"&redirect_uri={cls.REDIRECT_URI}"
|
||||
f"&scope={cls.BOT_SCOPE}"
|
||||
f"&state={state}"
|
||||
)
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def generate_dev_oauth_url(cls, state: str) -> str:
|
||||
"""dev mode workaround for localhost testing
|
||||
- https://www.nango.dev/blog/oauth-redirects-on-localhost-with-https
|
||||
"""
|
||||
|
||||
url = (
|
||||
f"https://slack.com/oauth/v2/authorize"
|
||||
f"?client_id={cls.CLIENT_ID}"
|
||||
f"&redirect_uri={cls.DEV_REDIRECT_URI}"
|
||||
f"&scope={cls.BOT_SCOPE}"
|
||||
f"&state={state}"
|
||||
)
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def session_dump_json(cls, email: str, redirect_on_success: str | None) -> str:
|
||||
"""Temporary state to store in redis. to be looked up on auth response.
|
||||
Returns a json string.
|
||||
"""
|
||||
session = SlackOAuth.OAuthSession(
|
||||
email=email, redirect_on_success=redirect_on_success
|
||||
)
|
||||
return session.model_dump_json()
|
||||
|
||||
@classmethod
|
||||
def parse_session(cls, session_json: str) -> OAuthSession:
|
||||
session = SlackOAuth.OAuthSession.model_validate_json(session_json)
|
||||
return session
|
||||
|
||||
|
||||
# Work in progress
|
||||
# class ConfluenceCloudOAuth:
|
||||
# """work in progress"""
|
||||
|
||||
# # https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/
|
||||
|
||||
# class OAuthSession(BaseModel):
|
||||
# """Stored in redis to be looked up on callback"""
|
||||
|
||||
# email: str
|
||||
# redirect_on_success: str | None # Where to send the user if OAuth flow succeeds
|
||||
|
||||
# CLIENT_ID = OAUTH_CONFLUENCE_CLIENT_ID
|
||||
# CLIENT_SECRET = OAUTH_CONFLUENCE_CLIENT_SECRET
|
||||
# TOKEN_URL = "https://auth.atlassian.com/oauth/token"
|
||||
|
||||
# # All read scopes per https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/
|
||||
# CONFLUENCE_OAUTH_SCOPE = (
|
||||
# "read:confluence-props%20"
|
||||
# "read:confluence-content.all%20"
|
||||
# "read:confluence-content.summary%20"
|
||||
# "read:confluence-content.permission%20"
|
||||
# "read:confluence-user%20"
|
||||
# "read:confluence-groups%20"
|
||||
# "readonly:content.attachment:confluence"
|
||||
# )
|
||||
|
||||
# REDIRECT_URI = f"{WEB_DOMAIN}/admin/connectors/confluence/oauth/callback"
|
||||
# DEV_REDIRECT_URI = f"https://redirectmeto.com/{REDIRECT_URI}"
|
||||
|
||||
# # eventually for Confluence Data Center
|
||||
# # oauth_url = (
|
||||
# # f"http://localhost:8090/rest/oauth/v2/authorize?client_id={CONFLUENCE_OAUTH_CLIENT_ID}"
|
||||
# # f"&scope={CONFLUENCE_OAUTH_SCOPE_2}"
|
||||
# # f"&redirect_uri={redirectme_uri}"
|
||||
# # )
|
||||
|
||||
# @classmethod
|
||||
# def generate_oauth_url(cls, state: str) -> str:
|
||||
# return cls._generate_oauth_url_helper(cls.REDIRECT_URI, state)
|
||||
|
||||
# @classmethod
|
||||
# def generate_dev_oauth_url(cls, state: str) -> str:
|
||||
# """dev mode workaround for localhost testing
|
||||
# - https://www.nango.dev/blog/oauth-redirects-on-localhost-with-https
|
||||
# """
|
||||
# return cls._generate_oauth_url_helper(cls.DEV_REDIRECT_URI, state)
|
||||
|
||||
# @classmethod
|
||||
# def _generate_oauth_url_helper(cls, redirect_uri: str, state: str) -> str:
|
||||
# url = (
|
||||
# "https://auth.atlassian.com/authorize"
|
||||
# f"?audience=api.atlassian.com"
|
||||
# f"&client_id={cls.CLIENT_ID}"
|
||||
# f"&redirect_uri={redirect_uri}"
|
||||
# f"&scope={cls.CONFLUENCE_OAUTH_SCOPE}"
|
||||
# f"&state={state}"
|
||||
# "&response_type=code"
|
||||
# "&prompt=consent"
|
||||
# )
|
||||
# return url
|
||||
|
||||
# @classmethod
|
||||
# def session_dump_json(cls, email: str, redirect_on_success: str | None) -> str:
|
||||
# """Temporary state to store in redis. to be looked up on auth response.
|
||||
# Returns a json string.
|
||||
# """
|
||||
# session = ConfluenceCloudOAuth.OAuthSession(
|
||||
# email=email, redirect_on_success=redirect_on_success
|
||||
# )
|
||||
# return session.model_dump_json()
|
||||
|
||||
# @classmethod
|
||||
# def parse_session(cls, session_json: str) -> SlackOAuth.OAuthSession:
|
||||
# session = SlackOAuth.OAuthSession.model_validate_json(session_json)
|
||||
# return session
|
||||
|
||||
|
||||
@router.post("/prepare-authorization-request")
|
||||
def prepare_authorization_request(
|
||||
connector: DocumentSource,
|
||||
redirect_on_success: str | None,
|
||||
user: User = Depends(current_user),
|
||||
tenant_id: str | None = Depends(get_current_tenant_id),
|
||||
) -> JSONResponse:
|
||||
"""Used by the frontend to generate the url for the user's browser during auth request.
|
||||
|
||||
Example: https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/
|
||||
"""
|
||||
|
||||
oauth_uuid = uuid.uuid4()
|
||||
oauth_uuid_str = str(oauth_uuid)
|
||||
oauth_state = (
|
||||
base64.urlsafe_b64encode(oauth_uuid.bytes).rstrip(b"=").decode("utf-8")
|
||||
)
|
||||
|
||||
if connector == DocumentSource.SLACK:
|
||||
oauth_url = SlackOAuth.generate_oauth_url(oauth_state)
|
||||
session = SlackOAuth.session_dump_json(
|
||||
email=user.email, redirect_on_success=redirect_on_success
|
||||
)
|
||||
# elif connector == DocumentSource.CONFLUENCE:
|
||||
# oauth_url = ConfluenceCloudOAuth.generate_oauth_url(oauth_state)
|
||||
# session = ConfluenceCloudOAuth.session_dump_json(
|
||||
# email=user.email, redirect_on_success=redirect_on_success
|
||||
# )
|
||||
# elif connector == DocumentSource.JIRA:
|
||||
# oauth_url = JiraCloudOAuth.generate_dev_oauth_url(oauth_state)
|
||||
# elif connector == DocumentSource.GOOGLE_DRIVE:
|
||||
# oauth_url = GoogleDriveOAuth.generate_dev_oauth_url(oauth_state)
|
||||
else:
|
||||
oauth_url = None
|
||||
|
||||
if not oauth_url:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"The document source type {connector} does not have OAuth implemented",
|
||||
)
|
||||
|
||||
r = get_redis_client(tenant_id=tenant_id)
|
||||
|
||||
# 10 min is the max we want an oauth flow to be valid
|
||||
r.set(f"da_oauth:{oauth_uuid_str}", session, ex=600)
|
||||
|
||||
return JSONResponse(content={"url": oauth_url})
|
||||
|
||||
|
||||
@router.post("/connector/slack/callback")
|
||||
def handle_slack_oauth_callback(
|
||||
code: str,
|
||||
state: str,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
tenant_id: str | None = Depends(get_current_tenant_id),
|
||||
) -> JSONResponse:
|
||||
if not SlackOAuth.CLIENT_ID or not SlackOAuth.CLIENT_SECRET:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Slack client ID or client secret is not configured.",
|
||||
)
|
||||
|
||||
r = get_redis_client(tenant_id=tenant_id)
|
||||
|
||||
# recover the state
|
||||
padded_state = state + "=" * (
|
||||
-len(state) % 4
|
||||
) # Add padding back (Base64 decoding requires padding)
|
||||
uuid_bytes = base64.urlsafe_b64decode(
|
||||
padded_state
|
||||
) # Decode the Base64 string back to bytes
|
||||
|
||||
# Convert bytes back to a UUID
|
||||
oauth_uuid = uuid.UUID(bytes=uuid_bytes)
|
||||
oauth_uuid_str = str(oauth_uuid)
|
||||
|
||||
r_key = f"da_oauth:{oauth_uuid_str}"
|
||||
|
||||
session_json_bytes = cast(bytes, r.get(r_key))
|
||||
if not session_json_bytes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Slack OAuth failed - OAuth state key not found: key={r_key}",
|
||||
)
|
||||
|
||||
session_json = session_json_bytes.decode("utf-8")
|
||||
try:
|
||||
session = SlackOAuth.parse_session(session_json)
|
||||
|
||||
# Exchange the authorization code for an access token
|
||||
response = requests.post(
|
||||
SlackOAuth.TOKEN_URL,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
"client_id": SlackOAuth.CLIENT_ID,
|
||||
"client_secret": SlackOAuth.CLIENT_SECRET,
|
||||
"code": code,
|
||||
"redirect_uri": SlackOAuth.REDIRECT_URI,
|
||||
},
|
||||
)
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
if not response_data.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Slack OAuth failed: {response_data.get('error')}",
|
||||
)
|
||||
|
||||
# Extract token and team information
|
||||
access_token: str = response_data.get("access_token")
|
||||
team_id: str = response_data.get("team", {}).get("id")
|
||||
authed_user_id: str = response_data.get("authed_user", {}).get("id")
|
||||
|
||||
credential_info = CredentialBase(
|
||||
credential_json={"slack_bot_token": access_token},
|
||||
admin_public=True,
|
||||
source=DocumentSource.SLACK,
|
||||
name="Slack OAuth",
|
||||
)
|
||||
|
||||
create_credential(credential_info, user, db_session)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"An error occurred during Slack OAuth: {str(e)}",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
r.delete(r_key)
|
||||
|
||||
# return the result
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Slack OAuth completed successfully.",
|
||||
"team_id": team_id,
|
||||
"authed_user_id": authed_user_id,
|
||||
"redirect_on_success": session.redirect_on_success,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Work in progress
|
||||
# @router.post("/connector/confluence/callback")
|
||||
# def handle_confluence_oauth_callback(
|
||||
# code: str,
|
||||
# state: str,
|
||||
# user: User = Depends(current_user),
|
||||
# db_session: Session = Depends(get_session),
|
||||
# tenant_id: str | None = Depends(get_current_tenant_id),
|
||||
# ) -> JSONResponse:
|
||||
# if not ConfluenceCloudOAuth.CLIENT_ID or not ConfluenceCloudOAuth.CLIENT_SECRET:
|
||||
# raise HTTPException(
|
||||
# status_code=500,
|
||||
# detail="Confluence client ID or client secret is not configured."
|
||||
# )
|
||||
|
||||
# r = get_redis_client(tenant_id=tenant_id)
|
||||
|
||||
# # recover the state
|
||||
# padded_state = state + '=' * (-len(state) % 4) # Add padding back (Base64 decoding requires padding)
|
||||
# uuid_bytes = base64.urlsafe_b64decode(padded_state) # Decode the Base64 string back to bytes
|
||||
|
||||
# # Convert bytes back to a UUID
|
||||
# oauth_uuid = uuid.UUID(bytes=uuid_bytes)
|
||||
# oauth_uuid_str = str(oauth_uuid)
|
||||
|
||||
# r_key = f"da_oauth:{oauth_uuid_str}"
|
||||
|
||||
# result = r.get(r_key)
|
||||
# if not result:
|
||||
# raise HTTPException(
|
||||
# status_code=400,
|
||||
# detail=f"Confluence OAuth failed - OAuth state key not found: key={r_key}"
|
||||
# )
|
||||
|
||||
# try:
|
||||
# session = ConfluenceCloudOAuth.parse_session(result)
|
||||
|
||||
# # Exchange the authorization code for an access token
|
||||
# response = requests.post(
|
||||
# ConfluenceCloudOAuth.TOKEN_URL,
|
||||
# headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
# data={
|
||||
# "client_id": ConfluenceCloudOAuth.CLIENT_ID,
|
||||
# "client_secret": ConfluenceCloudOAuth.CLIENT_SECRET,
|
||||
# "code": code,
|
||||
# "redirect_uri": ConfluenceCloudOAuth.DEV_REDIRECT_URI,
|
||||
# },
|
||||
# )
|
||||
|
||||
# response_data = response.json()
|
||||
|
||||
# if not response_data.get("ok"):
|
||||
# raise HTTPException(
|
||||
# status_code=400,
|
||||
# detail=f"ConfluenceCloudOAuth OAuth failed: {response_data.get('error')}"
|
||||
# )
|
||||
|
||||
# # Extract token and team information
|
||||
# access_token: str = response_data.get("access_token")
|
||||
# team_id: str = response_data.get("team", {}).get("id")
|
||||
# authed_user_id: str = response_data.get("authed_user", {}).get("id")
|
||||
|
||||
# credential_info = CredentialBase(
|
||||
# credential_json={"slack_bot_token": access_token},
|
||||
# admin_public=True,
|
||||
# source=DocumentSource.CONFLUENCE,
|
||||
# name="Confluence OAuth",
|
||||
# )
|
||||
|
||||
# logger.info(f"Slack access token: {access_token}")
|
||||
|
||||
# credential = create_credential(credential_info, user, db_session)
|
||||
|
||||
# logger.info(f"new_credential_id={credential.id}")
|
||||
# except Exception as e:
|
||||
# return JSONResponse(
|
||||
# status_code=500,
|
||||
# content={
|
||||
# "success": False,
|
||||
# "message": f"An error occurred during Slack OAuth: {str(e)}",
|
||||
# },
|
||||
# )
|
||||
# finally:
|
||||
# r.delete(r_key)
|
||||
|
||||
# # return the result
|
||||
# return JSONResponse(
|
||||
# content={
|
||||
# "success": True,
|
||||
# "message": "Slack OAuth completed successfully.",
|
||||
# "team_id": team_id,
|
||||
# "authed_user_id": authed_user_id,
|
||||
# "redirect_on_success": session.redirect_on_success,
|
||||
# }
|
||||
# )
|
||||
@@ -132,13 +132,18 @@ def _seed_personas(db_session: Session, personas: list[CreatePersonaRequest]) ->
|
||||
if personas:
|
||||
logger.notice("Seeding Personas")
|
||||
for persona in personas:
|
||||
if not persona.prompt_ids:
|
||||
raise ValueError(
|
||||
f"Invalid Persona with name {persona.name}; no prompts exist"
|
||||
)
|
||||
|
||||
upsert_persona(
|
||||
user=None, # Seeding is done as admin
|
||||
name=persona.name,
|
||||
description=persona.description,
|
||||
num_chunks=persona.num_chunks
|
||||
if persona.num_chunks is not None
|
||||
else 0.0,
|
||||
num_chunks=(
|
||||
persona.num_chunks if persona.num_chunks is not None else 0.0
|
||||
),
|
||||
llm_relevance_filter=persona.llm_relevance_filter,
|
||||
llm_filter_extraction=persona.llm_filter_extraction,
|
||||
recency_bias=RecencyBiasSetting.AUTO,
|
||||
|
||||
@@ -42,7 +42,7 @@ class PersonaManager:
|
||||
"is_public": is_public,
|
||||
"llm_filter_extraction": llm_filter_extraction,
|
||||
"recency_bias": recency_bias,
|
||||
"prompt_ids": prompt_ids or [],
|
||||
"prompt_ids": prompt_ids or [0],
|
||||
"document_set_ids": document_set_ids or [],
|
||||
"tool_ids": tool_ids or [],
|
||||
"llm_model_provider_override": llm_model_provider_override,
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "qa",
|
||||
"version": "0.2.0-dev",
|
||||
"version": "1.0.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qa",
|
||||
"version": "0.2.0-dev",
|
||||
"version": "1.0.0-dev",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qa",
|
||||
"version": "0.2.0-dev",
|
||||
"version": "1.0.0-dev",
|
||||
"version-comment": "version field must be SemVer or chromatic will barf",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const DanswerApiKeyForm = ({
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values, setFieldValue }) => (
|
||||
<Form>
|
||||
<Form className="w-full overflow-visible">
|
||||
<Text className="mb-4 text-lg">
|
||||
Choose a memorable name for your API key. This is optional and
|
||||
can be added or changed later!
|
||||
|
||||
@@ -45,9 +45,6 @@ function NewApiKeyModal({
|
||||
<div className="px-8 py-8">
|
||||
<div className="flex w-full border-b border-border mb-4 pb-4">
|
||||
<Title>New API Key</Title>
|
||||
<div onClick={onClose} className="ml-auto p-1 rounded hover:bg-hover">
|
||||
<FiX size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-32">
|
||||
<Text className="mb-4">
|
||||
|
||||
@@ -275,8 +275,9 @@ export function CustomLLMProviderUpdateForm({
|
||||
<SubLabel>
|
||||
<>
|
||||
<div>
|
||||
Additional configurations needed by the model provider. Are
|
||||
passed to litellm via environment variables.
|
||||
Additional configurations needed by the model provider. These
|
||||
are passed to litellm via environment + as arguments into the
|
||||
`completion` call.
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
@@ -290,14 +291,14 @@ export function CustomLLMProviderUpdateForm({
|
||||
<FieldArray
|
||||
name="custom_config_list"
|
||||
render={(arrayHelpers: ArrayHelpers<any[]>) => (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
{formikProps.values.custom_config_list.map((_, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={index === 0 ? "mt-2" : "mt-6"}
|
||||
className={(index === 0 ? "mt-2" : "mt-6") + " w-full"}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="flex w-full">
|
||||
<div className="w-full mr-6 border border-border p-3 rounded">
|
||||
<div>
|
||||
<Label>Key</Label>
|
||||
@@ -457,6 +458,7 @@ export function CustomLLMProviderUpdateForm({
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="ml-3"
|
||||
icon={FiTrash}
|
||||
onClick={async () => {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -9,9 +9,9 @@ import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { buildSimilarCredentialInfoURL } from "@/app/admin/connector/[ccPairId]/lib";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useFormContext } from "@/components/context/FormContext";
|
||||
import { getSourceDisplayName } from "@/lib/sources";
|
||||
import { getSourceDisplayName, getSourceMetadata } from "@/lib/sources";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { deleteCredential, linkCredential } from "@/lib/credential";
|
||||
import { submitFiles } from "./pages/utils/files";
|
||||
import { submitGoogleSite } from "./pages/utils/google_site";
|
||||
@@ -19,7 +19,11 @@ import AdvancedFormPage from "./pages/Advanced";
|
||||
import DynamicConnectionForm from "./pages/DynamicConnectorCreationForm";
|
||||
import CreateCredential from "@/components/credentials/actions/CreateCredential";
|
||||
import ModifyCredential from "@/components/credentials/actions/ModifyCredential";
|
||||
import { ConfigurableSources, ValidSources } from "@/lib/types";
|
||||
import {
|
||||
ConfigurableSources,
|
||||
oauthSupportedSources,
|
||||
ValidSources,
|
||||
} from "@/lib/types";
|
||||
import { Credential, credentialTemplates } from "@/lib/connectors/credentials";
|
||||
import {
|
||||
ConnectionConfiguration,
|
||||
@@ -43,6 +47,8 @@ import { Formik } from "formik";
|
||||
import NavigationRow from "./NavigationRow";
|
||||
import { useRouter } from "next/navigation";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { prepareOAuthAuthorizationRequest } from "@/lib/oauth_utils";
|
||||
import { EE_ENABLED, NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
export interface AdvancedConfig {
|
||||
refreshFreq: number;
|
||||
pruneFreq: number;
|
||||
@@ -110,6 +116,23 @@ export default function AddConnector({
|
||||
}: {
|
||||
connector: ConfigurableSources;
|
||||
}) {
|
||||
const [currentPageUrl, setCurrentPageUrl] = useState<string | null>(null);
|
||||
const [oauthUrl, setOauthUrl] = useState<string | null>(null);
|
||||
const [isAuthorizing, setIsAuthorizing] = useState(false);
|
||||
const [isAuthorizeVisible, setIsAuthorizeVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setCurrentPageUrl(window.location.href);
|
||||
}
|
||||
|
||||
if (EE_ENABLED && NEXT_PUBLIC_CLOUD_ENABLED) {
|
||||
const sourceMetadata = getSourceMetadata(connector);
|
||||
if (sourceMetadata?.oauthSupported == true) {
|
||||
setIsAuthorizeVisible(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// State for managing credentials and files
|
||||
@@ -135,8 +158,7 @@ export default function AddConnector({
|
||||
const configuration: ConnectionConfiguration = connectorConfigs[connector];
|
||||
|
||||
// Form context and popup management
|
||||
const { setFormStep, setAlowCreate, formStep, nextFormStep, prevFormStep } =
|
||||
useFormContext();
|
||||
const { setFormStep, setAllowCreate, formStep } = useFormContext();
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
// Hooks for Google Drive and Gmail credentials
|
||||
@@ -192,7 +214,7 @@ export default function AddConnector({
|
||||
|
||||
const onSwap = async (selectedCredential: Credential<any>) => {
|
||||
setCurrentCredential(selectedCredential);
|
||||
setAlowCreate(true);
|
||||
setAllowCreate(true);
|
||||
setPopup({
|
||||
message: "Swapped credential successfully!",
|
||||
type: "success",
|
||||
@@ -204,6 +226,37 @@ export default function AddConnector({
|
||||
router.push("/admin/indexing/status?message=connector-created");
|
||||
};
|
||||
|
||||
const handleAuthorize = async () => {
|
||||
// authorize button handler
|
||||
// gets an auth url from the server and directs the user to it in a popup
|
||||
|
||||
if (!currentPageUrl) return;
|
||||
|
||||
setIsAuthorizing(true);
|
||||
try {
|
||||
const response = await prepareOAuthAuthorizationRequest(
|
||||
connector,
|
||||
currentPageUrl
|
||||
);
|
||||
if (response.url) {
|
||||
setOauthUrl(response.url);
|
||||
window.open(response.url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
setPopup({ message: "Failed to fetch OAuth URL", type: "error" });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Narrow the type of error
|
||||
if (error instanceof Error) {
|
||||
setPopup({ message: `Error: ${error.message}`, type: "error" });
|
||||
} else {
|
||||
// Handle non-standard errors
|
||||
setPopup({ message: "An unknown error occurred", type: "error" });
|
||||
}
|
||||
} finally {
|
||||
setIsAuthorizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
@@ -385,16 +438,35 @@ export default function AddConnector({
|
||||
onSwitch={onSwap}
|
||||
/>
|
||||
{!createConnectorToggle && (
|
||||
<button
|
||||
className="mt-6 text-sm bg-background-900 px-2 py-1.5 flex text-text-200 flex-none rounded"
|
||||
onClick={() =>
|
||||
setCreateConnectorToggle(
|
||||
(createConnectorToggle) => !createConnectorToggle
|
||||
)
|
||||
}
|
||||
>
|
||||
Create New
|
||||
</button>
|
||||
<div className="mt-6 flex space-x-4">
|
||||
{/* Button to pop up a form to manually enter credentials */}
|
||||
<button
|
||||
className="mt-6 text-sm bg-background-900 px-2 py-1.5 flex text-text-200 flex-none rounded mr-4"
|
||||
onClick={() =>
|
||||
setCreateConnectorToggle(
|
||||
(createConnectorToggle) => !createConnectorToggle
|
||||
)
|
||||
}
|
||||
>
|
||||
Create New
|
||||
</button>
|
||||
{/* Button to sign in via OAuth */}
|
||||
{oauthSupportedSources.includes(connector) &&
|
||||
NEXT_PUBLIC_CLOUD_ENABLED && (
|
||||
<button
|
||||
onClick={handleAuthorize}
|
||||
className="mt-6 text-sm bg-blue-500 px-2 py-1.5 flex text-text-200 flex-none rounded"
|
||||
disabled={isAuthorizing}
|
||||
hidden={!isAuthorizeVisible}
|
||||
>
|
||||
{isAuthorizing
|
||||
? "Authorizing..."
|
||||
: `Authorize with ${getSourceDisplayName(
|
||||
connector
|
||||
)}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NOTE: connector will never be google_drive, since the ternary above will
|
||||
|
||||
111
web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx
Normal file
111
web/src/app/admin/connectors/[connector]/oauth/callback/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Title from "@/components/ui/title";
|
||||
import { KeyIcon } from "@/components/icons/icons";
|
||||
import { getSourceMetadata, isValidSource } from "@/lib/sources";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { handleOAuthAuthorizationResponse } from "@/lib/oauth_utils";
|
||||
|
||||
export default function OAuthCallbackPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [statusMessage, setStatusMessage] = useState("Processing...");
|
||||
const [statusDetails, setStatusDetails] = useState(
|
||||
"Please wait while we complete the setup."
|
||||
);
|
||||
const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [pageTitle, setPageTitle] = useState(
|
||||
"Authorize with Third-Party service"
|
||||
);
|
||||
|
||||
// Extract query parameters
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
const pathname = usePathname();
|
||||
const connector = pathname?.split("/")[3];
|
||||
|
||||
useEffect(() => {
|
||||
const handleOAuthCallback = async () => {
|
||||
if (!code || !state) {
|
||||
setStatusMessage("Improperly formed OAuth authorization request.");
|
||||
setStatusDetails(
|
||||
!code ? "Missing authorization code." : "Missing state parameter."
|
||||
);
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connector || !isValidSource(connector)) {
|
||||
setStatusMessage(
|
||||
`The specified connector source type ${connector} does not exist.`
|
||||
);
|
||||
setStatusDetails(`${connector} is not a valid source type.`);
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceMetadata = getSourceMetadata(connector as ValidSources);
|
||||
setPageTitle(`Authorize with ${sourceMetadata.displayName}`);
|
||||
|
||||
setStatusMessage("Processing...");
|
||||
setStatusDetails("Please wait while we complete authorization.");
|
||||
setIsError(false); // Ensure no error state during loading
|
||||
|
||||
try {
|
||||
const response = await handleOAuthAuthorizationResponse(code, state);
|
||||
|
||||
if (!response) {
|
||||
throw new Error("Empty response from OAuth server.");
|
||||
}
|
||||
|
||||
setStatusMessage("Success!");
|
||||
setStatusDetails(
|
||||
`Your authorization with ${sourceMetadata.displayName} completed successfully.`
|
||||
);
|
||||
setRedirectUrl(response.redirect_on_success); // Extract the redirect URL
|
||||
setIsError(false);
|
||||
} catch (error) {
|
||||
console.error("OAuth error:", error);
|
||||
setStatusMessage("Oops, something went wrong!");
|
||||
setStatusDetails(
|
||||
"An error occurred during the OAuth process. Please try again."
|
||||
);
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
handleOAuthCallback();
|
||||
}, [code, state, connector]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<AdminPageTitle title={pageTitle} icon={<KeyIcon size={32} />} />
|
||||
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<CardSection className="max-w-md">
|
||||
<h1 className="text-2xl font-bold mb-4">{statusMessage}</h1>
|
||||
<p className="text-text-500">{statusDetails}</p>
|
||||
{redirectUrl && !isError && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm">
|
||||
Click{" "}
|
||||
<a href={redirectUrl} className="text-blue-500 underline">
|
||||
here
|
||||
</a>{" "}
|
||||
to continue.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -353,13 +353,9 @@ export function CCPairIndexingStatusTable({
|
||||
);
|
||||
};
|
||||
const toggleSources = () => {
|
||||
const currentToggledCount =
|
||||
Object.values(connectorsToggled).filter(Boolean).length;
|
||||
const shouldToggleOn = currentToggledCount < sortedSources.length / 2;
|
||||
|
||||
const connectors = sortedSources.reduce(
|
||||
(acc, source) => {
|
||||
acc[source] = shouldToggleOn;
|
||||
acc[source] = shouldExpand;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<ValidSources, boolean>
|
||||
@@ -368,6 +364,7 @@ export function CCPairIndexingStatusTable({
|
||||
setConnectorsToggled(connectors);
|
||||
Cookies.set(TOGGLED_CONNECTORS_COOKIE_NAME, JSON.stringify(connectors));
|
||||
};
|
||||
|
||||
const shouldExpand =
|
||||
Object.values(connectorsToggled).filter(Boolean).length <
|
||||
sortedSources.length;
|
||||
|
||||
@@ -99,7 +99,7 @@ export function Modal({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full overflow-y-hidden flex flex-col h-full justify-stretch">
|
||||
<div className="w-full overflow-y-auto overflow-x-visible p-1 flex flex-col h-full justify-stretch">
|
||||
{title && (
|
||||
<>
|
||||
<div className="flex mb-4">
|
||||
@@ -117,7 +117,7 @@ export function Modal({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
noScroll ? "overflow-auto" : "overflow-x-hidden",
|
||||
noScroll ? "overflow-auto" : "overflow-x-visible",
|
||||
height || "max-h-[60vh]"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ interface FormContextType {
|
||||
allowAdvanced: boolean;
|
||||
setAllowAdvanced: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
allowCreate: boolean;
|
||||
setAlowCreate: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setAllowCreate: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const FormContext = createContext<FormContextType | undefined>(undefined);
|
||||
@@ -39,7 +39,7 @@ export const FormProvider: React.FC<{
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
|
||||
const [allowAdvanced, setAllowAdvanced] = useState(false);
|
||||
const [allowCreate, setAlowCreate] = useState(false);
|
||||
const [allowCreate, setAllowCreate] = useState(false);
|
||||
|
||||
const nextFormStep = (values = "") => {
|
||||
setFormStep((prevStep) => prevStep + 1);
|
||||
@@ -88,7 +88,7 @@ export const FormProvider: React.FC<{
|
||||
allowAdvanced,
|
||||
setAllowAdvanced,
|
||||
allowCreate,
|
||||
setAlowCreate,
|
||||
setAllowCreate,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import {
|
||||
ConnectorIndexingStatus,
|
||||
OAuthSlackCallbackResponse,
|
||||
DocumentBoostStatus,
|
||||
Tag,
|
||||
UserGroup,
|
||||
|
||||
80
web/src/lib/oauth_utils.ts
Normal file
80
web/src/lib/oauth_utils.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
OAuthPrepareAuthorizationResponse,
|
||||
OAuthSlackCallbackResponse,
|
||||
} from "./types";
|
||||
|
||||
// server side handler to help initiate the oauth authorization request
|
||||
export async function prepareOAuthAuthorizationRequest(
|
||||
connector: string,
|
||||
finalRedirect: string | null // a redirect (not the oauth redirect) for the user to return to after oauth is complete)
|
||||
): Promise<OAuthPrepareAuthorizationResponse> {
|
||||
let url = `/api/oauth/prepare-authorization-request?connector=${encodeURIComponent(
|
||||
connector
|
||||
)}`;
|
||||
|
||||
// Conditionally append the `redirect_on_success` parameter
|
||||
if (finalRedirect) {
|
||||
url += `&redirect_on_success=${encodeURIComponent(finalRedirect)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
connector: connector,
|
||||
redirect_on_success: finalRedirect,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to prepare OAuth authorization request: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
const data = (await response.json()) as OAuthPrepareAuthorizationResponse;
|
||||
return data;
|
||||
}
|
||||
|
||||
// server side handler to process the oauth redirect callback
|
||||
// https://api.slack.com/authentication/oauth-v2#exchanging
|
||||
export async function handleOAuthAuthorizationResponse(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<OAuthSlackCallbackResponse> {
|
||||
const url = `/api/oauth/connector/slack/callback?code=${encodeURIComponent(
|
||||
code
|
||||
)}&state=${encodeURIComponent(state)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ code, state }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetails = `Failed to handle OAuth authorization response: ${response.status}`;
|
||||
|
||||
try {
|
||||
const responseBody = await response.text(); // Read the body as text
|
||||
errorDetails += `\nResponse Body: ${responseBody}`;
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
errorDetails += `\nUnable to read response body: ${err.message}`;
|
||||
} else {
|
||||
errorDetails += `\nUnable to read response body: Unknown error type`;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorDetails);
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
const data = (await response.json()) as OAuthSlackCallbackResponse;
|
||||
return data;
|
||||
}
|
||||
@@ -124,6 +124,7 @@ export interface SourceMetadata {
|
||||
shortDescription?: string;
|
||||
internalName: ValidSources;
|
||||
adminUrl: string;
|
||||
oauthSupported?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchDefaultOverrides {
|
||||
|
||||
@@ -76,6 +76,7 @@ export const SOURCE_METADATA_MAP: SourceMap = {
|
||||
displayName: "Slack",
|
||||
category: SourceCategory.Messaging,
|
||||
docs: "https://docs.danswer.dev/connectors/slack",
|
||||
oauthSupported: true,
|
||||
},
|
||||
gmail: {
|
||||
icon: GmailIcon,
|
||||
@@ -341,6 +342,7 @@ export function listSourceMetadata(): SourceMetadata[] {
|
||||
export function getSourceDocLink(sourceType: ValidSources): string | null {
|
||||
return SOURCE_METADATA_MAP[sourceType].docs || null;
|
||||
}
|
||||
|
||||
export const isValidSource = (sourceType: string) => {
|
||||
return Object.keys(SOURCE_METADATA_MAP).includes(sourceType);
|
||||
};
|
||||
|
||||
@@ -135,6 +135,18 @@ export interface ConnectorIndexingStatus<
|
||||
in_progress: boolean;
|
||||
}
|
||||
|
||||
export interface OAuthPrepareAuthorizationResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface OAuthSlackCallbackResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
team_id: string;
|
||||
authed_user_id: string;
|
||||
redirect_on_success: string;
|
||||
}
|
||||
|
||||
export interface CCPairBasicInfo {
|
||||
has_successful_run: boolean;
|
||||
source: ValidSources;
|
||||
@@ -313,3 +325,9 @@ export type ConfigurableSources = Exclude<
|
||||
ValidSources,
|
||||
ValidSources.NotApplicable | ValidSources.IngestionApi
|
||||
>;
|
||||
|
||||
export const oauthSupportedSources: ConfigurableSources[] = [
|
||||
ValidSources.Slack,
|
||||
];
|
||||
|
||||
export type OAuthSupportedSource = (typeof oauthSupportedSources)[number];
|
||||
|
||||
65
web/tests/e2e/admin_oauth_redirect_uri.spec.ts
Normal file
65
web/tests/e2e/admin_oauth_redirect_uri.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from "@chromatic-com/playwright";
|
||||
|
||||
test(
|
||||
"Admin - OAuth Redirect - Missing Code",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
async ({ page }, testInfo) => {
|
||||
await page.goto(
|
||||
"http://localhost:3000/admin/connectors/slack/oauth/callback?state=xyz"
|
||||
);
|
||||
|
||||
await expect(page.locator("p.text-text-500")).toHaveText(
|
||||
"Missing authorization code."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"Admin - OAuth Redirect - Missing State",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
async ({ page }, testInfo) => {
|
||||
await page.goto(
|
||||
"http://localhost:3000/admin/connectors/slack/oauth/callback?code=123"
|
||||
);
|
||||
|
||||
await expect(page.locator("p.text-text-500")).toHaveText(
|
||||
"Missing state parameter."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"Admin - OAuth Redirect - Invalid Connector",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
async ({ page }, testInfo) => {
|
||||
await page.goto(
|
||||
"http://localhost:3000/admin/connectors/invalid-connector/oauth/callback?code=123&state=xyz"
|
||||
);
|
||||
|
||||
await expect(page.locator("p.text-text-500")).toHaveText(
|
||||
"invalid-connector is not a valid source type."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"Admin - OAuth Redirect - No Session",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
async ({ page }, testInfo) => {
|
||||
await page.goto(
|
||||
"http://localhost:3000/admin/connectors/slack/oauth/callback?code=123&state=xyz"
|
||||
);
|
||||
|
||||
await expect(page.locator("p.text-text-500")).toHaveText(
|
||||
"An error occurred during the OAuth process. Please try again."
|
||||
);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user