From c19ace2f4c96bba38928939fed517a77d9d6a25a Mon Sep 17 00:00:00 2001 From: pablodanswer Date: Wed, 18 Dec 2024 17:54:20 -0800 Subject: [PATCH] add anonymous user --- backend/onyx/auth/noauth_user.py | 7 +- backend/onyx/auth/users.py | 69 +++++++++++++----- backend/onyx/configs/constants.py | 1 + backend/onyx/server/auth_check.py | 2 + backend/onyx/server/features/persona/api.py | 3 +- backend/onyx/server/manage/get_state.py | 5 +- backend/onyx/server/manage/llm/api.py | 4 +- backend/onyx/server/manage/models.py | 4 + backend/onyx/server/manage/users.py | 5 +- .../server/query_and_chat/chat_backend.py | 7 +- .../onyx/server/query_and_chat/token_limit.py | 4 +- backend/onyx/server/settings/models.py | 1 + backend/onyx/server/settings/store.py | 33 ++++++--- .../common_utils/managers/settings.py | 73 +++++++++++++++++++ .../integration/common_utils/test_models.py | 16 ++++ .../anonymous_user/test_anonymous_user.py | 14 ++++ web/src/app/admin/settings/SettingsForm.tsx | 50 ++++++++++++- web/src/app/admin/settings/interfaces.ts | 1 + web/src/app/auth/login/page.tsx | 13 +++- web/src/app/auth/signup/page.tsx | 6 +- web/src/app/auth/verify-email/page.tsx | 2 +- .../app/auth/waiting-on-verification/page.tsx | 4 +- web/src/app/chat/ChatPage.tsx | 35 ++++----- web/src/app/chat/input/ChatInputBar.tsx | 15 +--- web/src/app/layout.tsx | 1 + web/src/app/not-found.tsx | 2 +- web/src/app/page.tsx | 2 +- web/src/components/UserDropdown.tsx | 10 +++ web/src/components/admin/Layout.tsx | 2 +- web/src/components/chat_search/Header.tsx | 4 + web/src/components/chat_search/hooks.ts | 5 ++ web/src/components/icons/icons.tsx | 10 +-- web/src/components/settings/lib.ts | 13 +++- web/src/lib/chat/fetchChatData.ts | 5 +- web/src/lib/types.ts | 1 + web/src/lib/userSS.ts | 10 ++- 36 files changed, 337 insertions(+), 102 deletions(-) create mode 100644 backend/tests/integration/common_utils/managers/settings.py create mode 100644 backend/tests/integration/tests/anonymous_user/test_anonymous_user.py diff --git a/backend/onyx/auth/noauth_user.py b/backend/onyx/auth/noauth_user.py index ac1557030c3..e17694894e0 100644 --- a/backend/onyx/auth/noauth_user.py +++ b/backend/onyx/auth/noauth_user.py @@ -30,13 +30,16 @@ def load_no_auth_user_preferences(store: KeyValueStore) -> UserPreferences: ) -def fetch_no_auth_user(store: KeyValueStore) -> UserInfo: +def fetch_no_auth_user( + store: KeyValueStore, *, anonymous_user_enabled: bool | None = None +) -> UserInfo: return UserInfo( id=NO_AUTH_USER_ID, email=NO_AUTH_USER_EMAIL, is_active=True, is_superuser=False, is_verified=True, - role=UserRole.ADMIN, + role=UserRole.BASIC if anonymous_user_enabled else UserRole.ADMIN, preferences=load_no_auth_user_preferences(store), + is_anonymous_user=anonymous_user_enabled, ) diff --git a/backend/onyx/auth/users.py b/backend/onyx/auth/users.py index 69f78f50ae3..1a359ca1e81 100644 --- a/backend/onyx/auth/users.py +++ b/backend/onyx/auth/users.py @@ -73,6 +73,7 @@ from onyx.configs.constants import DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN from onyx.configs.constants import DANSWER_API_KEY_PREFIX from onyx.configs.constants import MilestoneRecordType +from onyx.configs.constants import OnyxRedisLocks from onyx.configs.constants import PASSWORD_SPECIAL_CHARS from onyx.configs.constants import UNNAMED_KEY_PLACEHOLDER from onyx.db.api_key import fetch_user_for_api_key @@ -88,7 +89,7 @@ from onyx.db.models import OAuthAccount from onyx.db.models import User from onyx.db.users import get_user_by_email -from onyx.server.utils import BasicAuthenticationError +from onyx.redis.redis_pool import get_redis_client from onyx.utils.logger import setup_logger from onyx.utils.telemetry import create_milestone_and_report from onyx.utils.telemetry import optional_telemetry @@ -102,6 +103,11 @@ logger = setup_logger() +class BasicAuthenticationError(HTTPException): + def __init__(self, detail: str): + super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail) + + def is_user_admin(user: User | None) -> bool: if AUTH_TYPE == AuthType.DISABLED: return True @@ -142,6 +148,16 @@ def user_needs_to_be_verified() -> bool: return False +def anonymous_user_enabled() -> bool: + tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() + redis_client = get_redis_client(tenant_id=tenant_id) + value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED) + assert isinstance(value, bytes) + if value is None: + return False + return int(value.decode("utf-8")) == 1 + + def verify_email_is_invited(email: str) -> None: whitelist = get_invited_users() if not whitelist: @@ -705,32 +721,37 @@ async def optional_user( async def double_check_user( user: User | None, - optional: bool = DISABLE_AUTH, include_expired: bool = False, + allow_anonymous_access: bool = False, ) -> User | None: - if optional: + if DISABLE_AUTH: return None - if user is None: - raise BasicAuthenticationError( - detail="Access denied. User is not authenticated.", - ) + if user is not None: + # If user attempted to authenticate, verify them, do not default + # to anonymous access if it fails. + if user_needs_to_be_verified() and not user.is_verified: + raise BasicAuthenticationError( + detail="Access denied. User is not verified.", + ) - if user_needs_to_be_verified() and not user.is_verified: - raise BasicAuthenticationError( - detail="Access denied. User is not verified.", - ) + if ( + user.oidc_expiry + and user.oidc_expiry < datetime.now(timezone.utc) + and not include_expired + ): + raise BasicAuthenticationError( + detail="Access denied. User's OIDC token has expired.", + ) - if ( - user.oidc_expiry - and user.oidc_expiry < datetime.now(timezone.utc) - and not include_expired - ): - raise BasicAuthenticationError( - detail="Access denied. User's OIDC token has expired.", - ) + return user - return user + if allow_anonymous_access: + return None + + raise BasicAuthenticationError( + detail="Access denied. User is not authenticated.", + ) async def current_user_with_expired_token( @@ -745,6 +766,14 @@ async def current_limited_user( return await double_check_user(user) +async def current_chat_accesssible_user( + user: User | None = Depends(optional_user), +) -> User | None: + return await double_check_user( + user, allow_anonymous_access=anonymous_user_enabled() + ) + + async def current_user( user: User | None = Depends(optional_user), ) -> User | None: diff --git a/backend/onyx/configs/constants.py b/backend/onyx/configs/constants.py index d9e433df75f..a69850d89c3 100644 --- a/backend/onyx/configs/constants.py +++ b/backend/onyx/configs/constants.py @@ -273,6 +273,7 @@ class OnyxRedisLocks: SLACK_BOT_LOCK = "da_lock:slack_bot" SLACK_BOT_HEARTBEAT_PREFIX = "da_heartbeat:slack_bot" + ANONYMOUS_USER_ENABLED = "anonymous_user_enabled" class OnyxRedisSignals: diff --git a/backend/onyx/server/auth_check.py b/backend/onyx/server/auth_check.py index d55b275d127..d46762600fc 100644 --- a/backend/onyx/server/auth_check.py +++ b/backend/onyx/server/auth_check.py @@ -5,6 +5,7 @@ from starlette.routing import BaseRoute from onyx.auth.users import current_admin_user +from onyx.auth.users import current_chat_accesssible_user from onyx.auth.users import current_curator_or_admin_user from onyx.auth.users import current_limited_user from onyx.auth.users import current_user @@ -109,6 +110,7 @@ def check_router_auth( or depends_fn == current_curator_or_admin_user or depends_fn == api_key_dep or depends_fn == current_user_with_expired_token + or depends_fn == current_chat_accesssible_user or depends_fn == control_plane_dep or depends_fn == current_cloud_superuser ): diff --git a/backend/onyx/server/features/persona/api.py b/backend/onyx/server/features/persona/api.py index ece335fd2d1..814d81595ce 100644 --- a/backend/onyx/server/features/persona/api.py +++ b/backend/onyx/server/features/persona/api.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from onyx.auth.users import current_admin_user +from onyx.auth.users import current_chat_accesssible_user from onyx.auth.users import current_curator_or_admin_user from onyx.auth.users import current_limited_user from onyx.auth.users import current_user @@ -323,7 +324,7 @@ def get_image_generation_tool( @basic_router.get("") def list_personas( - user: User | None = Depends(current_user), + user: User | None = Depends(current_chat_accesssible_user), db_session: Session = Depends(get_session), include_deleted: bool = False, persona_ids: list[int] = Query(None), diff --git a/backend/onyx/server/manage/get_state.py b/backend/onyx/server/manage/get_state.py index 97748fe8620..48ea45c5af1 100644 --- a/backend/onyx/server/manage/get_state.py +++ b/backend/onyx/server/manage/get_state.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from onyx import __version__ +from onyx.auth.users import anonymous_user_enabled from onyx.auth.users import user_needs_to_be_verified from onyx.configs.app_configs import AUTH_TYPE from onyx.server.manage.models import AuthTypeResponse @@ -18,7 +19,9 @@ def healthcheck() -> StatusResponse: @router.get("/auth/type") def get_auth_type() -> AuthTypeResponse: return AuthTypeResponse( - auth_type=AUTH_TYPE, requires_verification=user_needs_to_be_verified() + auth_type=AUTH_TYPE, + requires_verification=user_needs_to_be_verified(), + anonymous_user_enabled=anonymous_user_enabled(), ) diff --git a/backend/onyx/server/manage/llm/api.py b/backend/onyx/server/manage/llm/api.py index e37b7951f39..402d57528f3 100644 --- a/backend/onyx/server/manage/llm/api.py +++ b/backend/onyx/server/manage/llm/api.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from onyx.auth.users import current_admin_user -from onyx.auth.users import current_user +from onyx.auth.users import current_chat_accesssible_user from onyx.db.engine import get_session from onyx.db.llm import fetch_existing_llm_providers from onyx.db.llm import fetch_provider @@ -190,7 +190,7 @@ def set_provider_as_default( @basic_router.get("/provider") def list_llm_provider_basics( - user: User | None = Depends(current_user), + user: User | None = Depends(current_chat_accesssible_user), db_session: Session = Depends(get_session), ) -> list[LLMProviderDescriptor]: return [ diff --git a/backend/onyx/server/manage/models.py b/backend/onyx/server/manage/models.py index 85951a1ba64..6dfb61a7fb2 100644 --- a/backend/onyx/server/manage/models.py +++ b/backend/onyx/server/manage/models.py @@ -37,6 +37,7 @@ class AuthTypeResponse(BaseModel): # specifies whether the current auth setup requires # users to have verified emails requires_verification: bool + anonymous_user_enabled: bool | None = None class UserPreferences(BaseModel): @@ -61,6 +62,7 @@ class UserInfo(BaseModel): current_token_expiry_length: int | None = None is_cloud_superuser: bool = False organization_name: str | None = None + is_anonymous_user: bool | None = None @classmethod def from_model( @@ -70,6 +72,7 @@ def from_model( expiry_length: int | None = None, is_cloud_superuser: bool = False, organization_name: str | None = None, + is_anonymous_user: bool | None = None, ) -> "UserInfo": return cls( id=str(user.id), @@ -96,6 +99,7 @@ def from_model( current_token_created_at=current_token_created_at, current_token_expiry_length=expiry_length, is_cloud_superuser=is_cloud_superuser, + is_anonymous_user=is_anonymous_user, ) diff --git a/backend/onyx/server/manage/users.py b/backend/onyx/server/manage/users.py index 27b8e23fa58..ee24ef93361 100644 --- a/backend/onyx/server/manage/users.py +++ b/backend/onyx/server/manage/users.py @@ -27,6 +27,7 @@ from onyx.auth.noauth_user import set_no_auth_user_preferences from onyx.auth.schemas import UserRole from onyx.auth.schemas import UserStatus +from onyx.auth.users import anonymous_user_enabled from onyx.auth.users import current_admin_user from onyx.auth.users import current_curator_or_admin_user from onyx.auth.users import current_user @@ -522,13 +523,15 @@ def verify_user_logged_in( # NOTE: this does not use `current_user` / `current_admin_user` because we don't want # to enforce user verification here - the frontend always wants to get the info about # the current user regardless of if they are currently verified - if user is None: # if auth type is disabled, return a dummy user with preferences from # the key-value store if AUTH_TYPE == AuthType.DISABLED: store = get_kv_store() return fetch_no_auth_user(store) + if anonymous_user_enabled(): + store = get_kv_store() + return fetch_no_auth_user(store, anonymous_user_enabled=True) raise BasicAuthenticationError(detail="User Not Authenticated") if user.oidc_expiry and user.oidc_expiry < datetime.now(timezone.utc): diff --git a/backend/onyx/server/query_and_chat/chat_backend.py b/backend/onyx/server/query_and_chat/chat_backend.py index b9e3dc06686..9cb4cf8eec7 100644 --- a/backend/onyx/server/query_and_chat/chat_backend.py +++ b/backend/onyx/server/query_and_chat/chat_backend.py @@ -19,6 +19,7 @@ from pydantic import BaseModel from sqlalchemy.orm import Session +from onyx.auth.users import current_chat_accesssible_user from onyx.auth.users import current_limited_user from onyx.auth.users import current_user from onyx.chat.chat_utils import create_chat_chain @@ -145,7 +146,7 @@ def update_chat_session_model( def get_chat_session( session_id: UUID, is_shared: bool = False, - user: User | None = Depends(current_user), + user: User | None = Depends(current_chat_accesssible_user), db_session: Session = Depends(get_session), ) -> ChatSessionDetailResponse: user_id = user.id if user is not None else None @@ -197,7 +198,7 @@ def get_chat_session( @router.post("/create-chat-session") def create_new_chat_session( chat_session_creation_request: ChatSessionCreationRequest, - user: User | None = Depends(current_user), + user: User | None = Depends(current_chat_accesssible_user), db_session: Session = Depends(get_session), ) -> CreateChatSessionID: user_id = user.id if user is not None else None @@ -330,7 +331,7 @@ def is_connected_sync() -> bool: def handle_new_chat_message( chat_message_req: CreateChatMessageRequest, request: Request, - user: User | None = Depends(current_limited_user), + user: User | None = Depends(current_chat_accesssible_user), _rate_limit_check: None = Depends(check_token_rate_limits), is_connected_func: Callable[[], bool] = Depends(is_connected), tenant_id: str = Depends(get_current_tenant_id), diff --git a/backend/onyx/server/query_and_chat/token_limit.py b/backend/onyx/server/query_and_chat/token_limit.py index c450410f21f..9922b077b9e 100644 --- a/backend/onyx/server/query_and_chat/token_limit.py +++ b/backend/onyx/server/query_and_chat/token_limit.py @@ -11,7 +11,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session -from onyx.auth.users import current_user +from onyx.auth.users import current_chat_accesssible_user from onyx.db.engine import get_session_context_manager from onyx.db.engine import get_session_with_tenant from onyx.db.models import ChatMessage @@ -31,7 +31,7 @@ def check_token_rate_limits( - user: User | None = Depends(current_user), + user: User | None = Depends(current_chat_accesssible_user), ) -> None: # short circuit if no rate limits are set up # NOTE: result of `any_rate_limit_exists` is cached, so this call is fast 99% of the time diff --git a/backend/onyx/server/settings/models.py b/backend/onyx/server/settings/models.py index 7f0e03ffe11..90501418bc7 100644 --- a/backend/onyx/server/settings/models.py +++ b/backend/onyx/server/settings/models.py @@ -44,6 +44,7 @@ class Settings(BaseModel): maximum_chat_retention_days: int | None = None gpu_enabled: bool | None = None product_gating: GatingType = GatingType.NONE + anonymous_user_enabled: bool | None = None class UserSettings(Settings): diff --git a/backend/onyx/server/settings/store.py b/backend/onyx/server/settings/store.py index 17a2dd8a9f2..1fe5793e6a3 100644 --- a/backend/onyx/server/settings/store.py +++ b/backend/onyx/server/settings/store.py @@ -1,21 +1,34 @@ -from typing import cast - from onyx.configs.constants import KV_SETTINGS_KEY +from onyx.configs.constants import OnyxRedisLocks from onyx.key_value_store.factory import get_kv_store -from onyx.key_value_store.interface import KvKeyNotFoundError +from onyx.redis.redis_pool import get_redis_client from onyx.server.settings.models import Settings +from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR def load_settings() -> Settings: - dynamic_config_store = get_kv_store() - try: - settings = Settings(**cast(dict, dynamic_config_store.load(KV_SETTINGS_KEY))) - except KvKeyNotFoundError: - settings = Settings() - dynamic_config_store.store(KV_SETTINGS_KEY, settings.model_dump()) - + tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() + redis_client = get_redis_client(tenant_id=tenant_id) + value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED) + if value is not None: + assert isinstance(value, bytes) + anonymous_user_enabled = int(value.decode("utf-8")) == 1 + else: + # Default to False + anonymous_user_enabled = False + # Optionally store the default back to Redis + redis_client.set(OnyxRedisLocks.ANONYMOUS_USER_ENABLED, "0") + settings = Settings(anonymous_user_enabled=anonymous_user_enabled) return settings def store_settings(settings: Settings) -> None: + if settings.anonymous_user_enabled is not None: + tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() + redis_client = get_redis_client(tenant_id=tenant_id) + redis_client.set( + OnyxRedisLocks.ANONYMOUS_USER_ENABLED, + "1" if settings.anonymous_user_enabled else "0", + ) + get_kv_store().store(KV_SETTINGS_KEY, settings.model_dump()) diff --git a/backend/tests/integration/common_utils/managers/settings.py b/backend/tests/integration/common_utils/managers/settings.py new file mode 100644 index 00000000000..e2b24ef8b14 --- /dev/null +++ b/backend/tests/integration/common_utils/managers/settings.py @@ -0,0 +1,73 @@ +from typing import Any +from typing import Dict +from typing import Optional + +import requests + +from tests.integration.common_utils.constants import API_SERVER_URL +from tests.integration.common_utils.constants import GENERAL_HEADERS +from tests.integration.common_utils.test_models import DATestSettings +from tests.integration.common_utils.test_models import DATestUser + + +class SettingsManager: + @staticmethod + def get_settings( + user_performing_action: DATestUser | None = None, + ) -> tuple[Dict[str, Any], str]: + headers = ( + user_performing_action.headers + if user_performing_action + else GENERAL_HEADERS + ) + headers.pop("Content-Type", None) + + response = requests.get( + f"{API_SERVER_URL}/api/manage/admin/settings", + headers=headers, + ) + + if not response.ok: + return ( + {}, + f"Failed to get settings - {response.json().get('detail', 'Unknown error')}", + ) + + return response.json(), "" + + @staticmethod + def update_settings( + settings: DATestSettings, + user_performing_action: DATestUser | None = None, + ) -> tuple[Dict[str, Any], str]: + headers = ( + user_performing_action.headers + if user_performing_action + else GENERAL_HEADERS + ) + headers.pop("Content-Type", None) + + payload = settings.model_dump() + response = requests.patch( + f"{API_SERVER_URL}/api/manage/admin/settings", + json=payload, + headers=headers, + ) + + if not response.ok: + return ( + {}, + f"Failed to update settings - {response.json().get('detail', 'Unknown error')}", + ) + + return response.json(), "" + + @staticmethod + def get_setting( + key: str, + user_performing_action: DATestUser | None = None, + ) -> Optional[Any]: + settings, error = SettingsManager.get_settings(user_performing_action) + if error: + return None + return settings.get(key) diff --git a/backend/tests/integration/common_utils/test_models.py b/backend/tests/integration/common_utils/test_models.py index 36eb2c9ecec..540392a5c96 100644 --- a/backend/tests/integration/common_utils/test_models.py +++ b/backend/tests/integration/common_utils/test_models.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Any from uuid import UUID @@ -150,3 +151,18 @@ class StreamedResponse(BaseModel): relevance_summaries: list[dict[str, Any]] | None = None tool_result: Any | None = None user: str | None = None + + +class DATestGatingType(str, Enum): + FULL = "full" + PARTIAL = "partial" + NONE = "none" + + +class DATestSettings(BaseModel): + """General settings""" + + maximum_chat_retention_days: int | None = None + gpu_enabled: bool | None = None + product_gating: DATestGatingType = DATestGatingType.NONE + anonymous_user_enabled: bool | None = None diff --git a/backend/tests/integration/tests/anonymous_user/test_anonymous_user.py b/backend/tests/integration/tests/anonymous_user/test_anonymous_user.py new file mode 100644 index 00000000000..101524a0fc1 --- /dev/null +++ b/backend/tests/integration/tests/anonymous_user/test_anonymous_user.py @@ -0,0 +1,14 @@ +from tests.integration.common_utils.managers.settings import SettingsManager +from tests.integration.common_utils.managers.user import UserManager +from tests.integration.common_utils.test_models import DATestSettings +from tests.integration.common_utils.test_models import DATestUser + + +def test_limited(reset: None) -> None: + """Verify that with a limited role key, limited endpoints are accessible and + others are not.""" + + # Creating an admin user (first user created is automatically an admin) + admin_user: DATestUser = UserManager.create(name="admin_user") + SettingsManager.update_settings(DATestSettings(anonymous_user_enabled=True)) + print(admin_user.headers) diff --git a/web/src/app/admin/settings/SettingsForm.tsx b/web/src/app/admin/settings/SettingsForm.tsx index cd95795b8b9..df6889012c8 100644 --- a/web/src/app/admin/settings/SettingsForm.tsx +++ b/web/src/app/admin/settings/SettingsForm.tsx @@ -10,6 +10,7 @@ import { DefaultDropdown, Option } from "@/components/Dropdown"; import React, { useContext, useState, useEffect } from "react"; import { SettingsContext } from "@/components/settings/SettingsProvider"; import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; +import { Modal } from "@/components/Modal"; export function Checkbox({ label, @@ -102,6 +103,7 @@ function IntegerInput({ export function SettingsForm() { const router = useRouter(); + const [showConfirmModal, setShowConfirmModal] = useState(false); const [settings, setSettings] = useState(null); const [chatRetention, setChatRetention] = useState(""); const { popup, setPopup } = usePopup(); @@ -171,11 +173,22 @@ export function SettingsForm() { fieldName: keyof Settings, checked: boolean ) { + if (fieldName === "anonymous_user_enabled" && checked) { + setShowConfirmModal(true); + } else { + const updates: { fieldName: keyof Settings; newValue: any }[] = [ + { fieldName, newValue: checked }, + ]; + updateSettingField(updates); + } + } + + function handleConfirmAnonymousUsers() { const updates: { fieldName: keyof Settings; newValue: any }[] = [ - { fieldName, newValue: checked }, + { fieldName: "anonymous_user_enabled", newValue: true }, ]; - updateSettingField(updates); + setShowConfirmModal(false); } function handleSetChatRetention() { @@ -205,10 +218,41 @@ export function SettingsForm() { handleToggleSettingsField("auto_scroll", e.target.checked) } /> + + handleToggleSettingsField("anonymous_user_enabled", e.target.checked) + } + /> + {showConfirmModal && ( + setShowConfirmModal(false)} + > +
+

Enable Anonymous Users

+

+ Are you sure you want to enable anonymous users? This will allow + anyone to use Danswer without signing in. +

+
+ + +
+
+
+ )} {isEnterpriseEnabled && ( <> - Chat Settings + Chat Settings 0) ) { if (authTypeMetadata?.requiresVerification && !currentUser.is_verified) { return redirect("/auth/waiting-on-verification"); } - return redirect("/"); + return redirect("/chat"); } // get where to send the user to authenticate @@ -105,7 +106,9 @@ const Page = async (props: { Don't have an account?{" "} Create an account @@ -127,7 +130,9 @@ const Page = async (props: { Don't have an account?{" "} Create an account diff --git a/web/src/app/auth/signup/page.tsx b/web/src/app/auth/signup/page.tsx index 3e25e36c4c8..b2726f91f50 100644 --- a/web/src/app/auth/signup/page.tsx +++ b/web/src/app/auth/signup/page.tsx @@ -39,13 +39,13 @@ const Page = async (props: { // simply take the user to the home page if Auth is disabled if (authTypeMetadata?.authType === "disabled") { - return redirect("/"); + return redirect("/chat"); } // if user is already logged in, take them to the main app page if (currentUser && currentUser.is_active) { if (!authTypeMetadata?.requiresVerification || currentUser.is_verified) { - return redirect("/"); + return redirect("/chat"); } return redirect("/auth/waiting-on-verification"); } @@ -53,7 +53,7 @@ const Page = async (props: { // only enable this page if basic login is enabled if (authTypeMetadata?.authType !== "basic" && !cloud) { - return redirect("/"); + return redirect("/chat"); } let authUrl: string | null = null; diff --git a/web/src/app/auth/verify-email/page.tsx b/web/src/app/auth/verify-email/page.tsx index f6faa075b9f..2c70c98d7ab 100644 --- a/web/src/app/auth/verify-email/page.tsx +++ b/web/src/app/auth/verify-email/page.tsx @@ -23,7 +23,7 @@ export default async function Page() { } if (!authTypeMetadata?.requiresVerification || currentUser?.is_verified) { - return redirect("/"); + return redirect("/chat"); } return ; diff --git a/web/src/app/auth/waiting-on-verification/page.tsx b/web/src/app/auth/waiting-on-verification/page.tsx index 3b684cd56fd..278877ae2f4 100644 --- a/web/src/app/auth/waiting-on-verification/page.tsx +++ b/web/src/app/auth/waiting-on-verification/page.tsx @@ -27,13 +27,13 @@ export default async function Page() { if (!currentUser) { if (authTypeMetadata?.authType === "disabled") { - return redirect("/"); + return redirect("/chat"); } return redirect("/auth/login"); } if (!authTypeMetadata?.requiresVerification || currentUser.is_verified) { - return redirect("/"); + return redirect("/chat"); } return ( diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 1438920e677..7071c252441 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -243,10 +243,10 @@ export function ChatPage({ (assistant) => assistant.id === existingChatSessionAssistantId ) : defaultAssistantId !== undefined - ? availableAssistants.find( - (assistant) => assistant.id === defaultAssistantId - ) - : undefined + ? availableAssistants.find( + (assistant) => assistant.id === defaultAssistantId + ) + : undefined ); // Gather default temperature settings const search_param_temperature = searchParams.get( @@ -256,12 +256,12 @@ export function ChatPage({ const defaultTemperature = search_param_temperature ? parseFloat(search_param_temperature) : selectedAssistant?.tools.some( - (tool) => - tool.in_code_tool_id === "SearchTool" || - tool.in_code_tool_id === "InternetSearchTool" - ) - ? 0 - : 0.7; + (tool) => + tool.in_code_tool_id === "SearchTool" || + tool.in_code_tool_id === "InternetSearchTool" + ) + ? 0 + : 0.7; const setSelectedAssistantFromId = (assistantId: number) => { // NOTE: also intentionally look through available assistants here, so that @@ -1177,8 +1177,8 @@ export function ChatPage({ const currentAssistantId = alternativeAssistantOverride ? alternativeAssistantOverride.id : alternativeAssistant - ? alternativeAssistant.id - : liveAssistant.id; + ? alternativeAssistant.id + : liveAssistant.id; resetInputBar(); let messageUpdates: Message[] | null = null; @@ -1661,6 +1661,7 @@ export function ChatPage({ setShowDocSidebar: setShowHistorySidebar, setToggled: removeToggle, mobile: settings?.isMobile, + isAnonymousUser: user?.is_anonymous_user, }); const autoScrollEnabled = @@ -2228,6 +2229,7 @@ export function ChatPage({ toggleSidebar={toggleSidebar} currentChatSession={selectedChatSession} documentSidebarToggled={documentSidebarToggled} + hideUserDropdown={user?.is_anonymous_user} /> )} @@ -2766,12 +2768,6 @@ export function ChatPage({ setFiltersToggled(false); setDocumentSidebarToggled(true); }} - removeFilters={() => { - filterManager.setSelectedSources([]); - filterManager.setSelectedTags([]); - filterManager.setSelectedDocumentSets([]); - setDocumentSidebarToggled(false); - }} showConfigureAPIKey={() => setShowApiKeyModal(true) } @@ -2781,7 +2777,6 @@ export function ChatPage({ selectedDocuments={selectedDocuments} // assistant stuff selectedAssistant={liveAssistant} - setSelectedAssistant={onAssistantChange} setAlternativeAssistant={setAlternativeAssistant} alternativeAssistant={alternativeAssistant} // end assistant stuff @@ -2789,7 +2784,6 @@ export function ChatPage({ setMessage={setMessage} onSubmit={onSubmit} filterManager={filterManager} - llmOverrideManager={llmOverrideManager} files={currentMessageFiles} setFiles={setCurrentMessageFiles} toggleFilters={ @@ -2797,7 +2791,6 @@ export function ChatPage({ } handleFileUpload={handleImageUpload} textAreaRef={textAreaRef} - chatSessionId={chatSessionIdRef.current!} /> {enterpriseSettings && enterpriseSettings.custom_lower_disclaimer_content && ( diff --git a/web/src/app/chat/input/ChatInputBar.tsx b/web/src/app/chat/input/ChatInputBar.tsx index ac7a278308e..c5c21c44083 100644 --- a/web/src/app/chat/input/ChatInputBar.tsx +++ b/web/src/app/chat/input/ChatInputBar.tsx @@ -3,8 +3,7 @@ import { FiPlusCircle, FiPlus, FiInfo, FiX, FiSearch } from "react-icons/fi"; import { ChatInputOption } from "./ChatInputOption"; import { Persona } from "@/app/admin/assistants/interfaces"; -import { FilterManager, LlmOverrideManager } from "@/lib/hooks"; -import { SelectedFilterDisplay } from "./SelectedFilterDisplay"; +import { FilterManager } from "@/lib/hooks"; import { useChatContext } from "@/components/context/ChatContext"; import { getFinalLLM } from "@/lib/llm/utils"; import { ChatFileType, FileDescriptor } from "../interfaces"; @@ -37,9 +36,9 @@ import FiltersDisplay from "./FilterDisplay"; const MAX_INPUT_HEIGHT = 200; interface ChatInputBarProps { - removeFilters: () => void; removeDocs: () => void; openModelSettings: () => void; + showDocs: () => void; showConfigureAPIKey: () => void; selectedDocuments: OnyxDocument[]; message: string; @@ -47,41 +46,34 @@ interface ChatInputBarProps { stopGenerating: () => void; onSubmit: () => void; filterManager: FilterManager; - llmOverrideManager: LlmOverrideManager; chatState: ChatState; - showDocs: () => void; alternativeAssistant: Persona | null; // assistants selectedAssistant: Persona; - setSelectedAssistant: (assistant: Persona) => void; setAlternativeAssistant: (alternativeAssistant: Persona | null) => void; files: FileDescriptor[]; setFiles: (files: FileDescriptor[]) => void; handleFileUpload: (files: File[]) => void; textAreaRef: React.RefObject; - chatSessionId?: string; toggleFilters?: () => void; } export function ChatInputBar({ - removeFilters, removeDocs, openModelSettings, - showConfigureAPIKey, showDocs, + showConfigureAPIKey, selectedDocuments, message, setMessage, stopGenerating, onSubmit, filterManager, - llmOverrideManager, chatState, // assistants selectedAssistant, - setSelectedAssistant, setAlternativeAssistant, files, @@ -89,7 +81,6 @@ export function ChatInputBar({ handleFileUpload, textAreaRef, alternativeAssistant, - chatSessionId, toggleFilters, }: ChatInputBarProps) { useEffect(() => { diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index de39458ef5c..c8d15792fac 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -156,6 +156,7 @@ export default async function RootLayout({ ); } + if (productGating === GatingType.FULL) { return getPageContent(
diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx index 6866db7cbbb..3eb4f1b948f 100644 --- a/web/src/app/not-found.tsx +++ b/web/src/app/not-found.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function NotFound() { - redirect("/chat"); + redirect("/auth/login"); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 557deca870a..78b4bf9e612 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default async function Page() { - redirect("/chat"); + redirect("/auth/login"); } diff --git a/web/src/components/UserDropdown.tsx b/web/src/components/UserDropdown.tsx index e2fd8fce167..01eaf20be08 100644 --- a/web/src/components/UserDropdown.tsx +++ b/web/src/components/UserDropdown.tsx @@ -59,9 +59,11 @@ const DropdownOption: React.FC = ({ export function UserDropdown({ page, toggleUserSettings, + hideUserDropdown, }: { page?: pageType; toggleUserSettings?: () => void; + hideUserDropdown?: boolean; }) { const { user, isCurator } = useUser(); const [userInfoVisible, setUserInfoVisible] = useState(false); @@ -114,6 +116,7 @@ export function UserDropdown({ }; const showAdminPanel = !user || user.role === UserRole.ADMIN; + const showCuratorPanel = user && isCurator; const showLogout = user && !checkUserIsNoAuthUser(user.id) && !LOGOUT_DISABLED; @@ -183,6 +186,12 @@ export function UserDropdown({ notifications={notifications || []} refreshNotifications={refreshNotifications} /> + ) : hideUserDropdown ? ( + router.push("/auth/login")} + icon={} + label="Log In" + /> ) : ( <> {customNavItems.map((item, i) => ( @@ -251,6 +260,7 @@ export function UserDropdown({ label="User Settings" /> )} + { setUserInfoVisible(true); diff --git a/web/src/components/admin/Layout.tsx b/web/src/components/admin/Layout.tsx index 6c8cfce41bc..9d795781c16 100644 --- a/web/src/components/admin/Layout.tsx +++ b/web/src/components/admin/Layout.tsx @@ -35,7 +35,7 @@ export async function Layout({ children }: { children: React.ReactNode }) { return redirect("/auth/login"); } if (user.role === UserRole.BASIC) { - return redirect("/"); + return redirect("/chat"); } if (!user.is_verified && requiresVerification) { return redirect("/auth/waiting-on-verification"); diff --git a/web/src/components/chat_search/Header.tsx b/web/src/components/chat_search/Header.tsx index 3050b56dba6..db6720106ce 100644 --- a/web/src/components/chat_search/Header.tsx +++ b/web/src/components/chat_search/Header.tsx @@ -21,6 +21,7 @@ export default function FunctionalHeader({ sidebarToggled, documentSidebarToggled, toggleUserSettings, + hideUserDropdown, }: { reset?: () => void; page: pageType; @@ -30,6 +31,7 @@ export default function FunctionalHeader({ setSharingModalVisible?: (value: SetStateAction) => void; toggleSidebar?: () => void; toggleUserSettings?: () => void; + hideUserDropdown?: boolean; }) { const settings = useContext(SettingsContext); useEffect(() => { @@ -114,8 +116,10 @@ export default function FunctionalHeader({
)} +
diff --git a/web/src/components/chat_search/hooks.ts b/web/src/components/chat_search/hooks.ts index d83059636bc..e1cb683f823 100644 --- a/web/src/components/chat_search/hooks.ts +++ b/web/src/components/chat_search/hooks.ts @@ -7,6 +7,7 @@ interface UseSidebarVisibilityProps { setShowDocSidebar: Dispatch>; mobile?: boolean; setToggled?: () => void; + isAnonymousUser?: boolean; } export const useSidebarVisibility = ({ @@ -16,11 +17,15 @@ export const useSidebarVisibility = ({ setToggled, showDocSidebar, mobile, + isAnonymousUser, }: UseSidebarVisibilityProps) => { const xPosition = useRef(0); useEffect(() => { const handleEvent = (event: MouseEvent) => { + if (isAnonymousUser) { + return; + } const currentXPosition = event.clientX; xPosition.current = currentXPosition; diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx index 44ec4ad259d..5d6ebc6a3b5 100644 --- a/web/src/components/icons/icons.tsx +++ b/web/src/components/icons/icons.tsx @@ -2621,7 +2621,7 @@ export const OpenIcon = ({ @@ -2645,7 +2645,7 @@ export const DexpandTwoIcon = ({ @@ -2669,7 +2669,7 @@ export const ExpandTwoIcon = ({ @@ -2693,7 +2693,7 @@ export const DownloadCSVIcon = ({ @@ -2717,7 +2717,7 @@ export const UserIcon = ({ { maximum_chat_retention_days: null, notifications: [], needs_reindexing: false, + anonymous_user_enabled: false, }; } else { throw new Error( - `fetchStandardSettingsSS failed: status=${results[0].status} body=${await results[0].text()}` + `fetchStandardSettingsSS failed: status=${ + results[0].status + } body=${await results[0].text()}` ); } } else { @@ -64,7 +67,9 @@ export async function fetchSettingsSS(): Promise { if (!results[1].ok) { if (results[1].status !== 403 && results[1].status !== 401) { throw new Error( - `fetchEnterpriseSettingsSS failed: status=${results[1].status} body=${await results[1].text()}` + `fetchEnterpriseSettingsSS failed: status=${ + results[1].status + } body=${await results[1].text()}` ); } } else { @@ -77,7 +82,9 @@ export async function fetchSettingsSS(): Promise { if (!results[2].ok) { if (results[2].status !== 403) { throw new Error( - `fetchCustomAnalyticsScriptSS failed: status=${results[2].status} body=${await results[2].text()}` + `fetchCustomAnalyticsScriptSS failed: status=${ + results[2].status + } body=${await results[2].text()}` ); } } else { diff --git a/web/src/lib/chat/fetchChatData.ts b/web/src/lib/chat/fetchChatData.ts index 6ba67bf3e59..d4f3dfa2a2f 100644 --- a/web/src/lib/chat/fetchChatData.ts +++ b/web/src/lib/chat/fetchChatData.ts @@ -86,7 +86,9 @@ export async function fetchChatData(searchParams: { const foldersResponse = results[7] as Response | null; const authDisabled = authTypeMetadata?.authType === "disabled"; - if (!authDisabled && !user) { + + // TODO Validate need + if (!authDisabled && !user && !authTypeMetadata?.anonymousUserEnabled) { const headersList = await headers(); const fullUrl = headersList.get("x-url") || "/chat"; const searchParamsString = new URLSearchParams( @@ -95,6 +97,7 @@ export async function fetchChatData(searchParams: { const redirectUrl = searchParamsString ? `${fullUrl}?${searchParamsString}` : fullUrl; + return redirect(`/auth/login?next=${encodeURIComponent(redirectUrl)}`); } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 439066aa6df..f1f347583a8 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -62,6 +62,7 @@ export interface User { oidc_expiry?: Date; is_cloud_superuser?: boolean; organization_name: string | null; + is_anonymous_user?: boolean; } export interface MinimalUserSnapshot { diff --git a/web/src/lib/userSS.ts b/web/src/lib/userSS.ts index 9e7de4acb2e..94564c65ffc 100644 --- a/web/src/lib/userSS.ts +++ b/web/src/lib/userSS.ts @@ -8,6 +8,7 @@ export interface AuthTypeMetadata { authType: AuthType; autoRedirect: boolean; requiresVerification: boolean; + anonymousUserEnabled: boolean | null; } export const getAuthTypeMetadataSS = async (): Promise => { @@ -16,8 +17,11 @@ export const getAuthTypeMetadataSS = async (): Promise => { throw new Error("Failed to fetch data"); } - const data: { auth_type: string; requires_verification: boolean } = - await res.json(); + const data: { + auth_type: string; + requires_verification: boolean; + anonymous_user_enabled: boolean | null; + } = await res.json(); let authType: AuthType; @@ -35,12 +39,14 @@ export const getAuthTypeMetadataSS = async (): Promise => { authType, autoRedirect: true, requiresVerification: data.requires_verification, + anonymousUserEnabled: data.anonymous_user_enabled, }; } return { authType, autoRedirect: false, requiresVerification: data.requires_verification, + anonymousUserEnabled: data.anonymous_user_enabled, }; };