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 eb337da90be..b9a9e941751 100644 --- a/backend/onyx/auth/users.py +++ b/backend/onyx/auth/users.py @@ -69,6 +69,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 @@ -84,7 +85,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 @@ -98,6 +99,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 @@ -138,6 +144,20 @@ def user_needs_to_be_verified() -> bool: return False +def anonymous_user_enabled() -> bool: + if MULTI_TENANT: + return False + + redis_client = get_redis_client(tenant_id=None) + value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED) + + if value is None: + return False + + assert isinstance(value, bytes) + return int(value.decode("utf-8")) == 1 + + def verify_email_is_invited(email: str) -> None: whitelist = get_invited_users() if not whitelist: @@ -690,30 +710,36 @@ 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: - return None + return user - 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( @@ -728,6 +754,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 7892dafafd7..994ae738cbf 100644 --- a/backend/onyx/configs/constants.py +++ b/backend/onyx/configs/constants.py @@ -275,6 +275,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/documents/connector.py b/backend/onyx/server/documents/connector.py index 4fab885195a..5337be80bc0 100644 --- a/backend/onyx/server/documents/connector.py +++ b/backend/onyx/server/documents/connector.py @@ -14,6 +14,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_user from onyx.background.celery.celery_utils import get_deletion_attempt_snapshot @@ -1055,7 +1056,7 @@ class BasicCCPairInfo(BaseModel): @router.get("/connector-status") def get_basic_connector_indexing_status( - _: User = Depends(current_user), + _: User = Depends(current_chat_accesssible_user), db_session: Session = Depends(get_session), ) -> list[BasicCCPairInfo]: cc_pairs = get_connector_credential_pairs(db_session) 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 e2066ef77ef..cb37a464df6 100644 --- a/backend/onyx/server/manage/users.py +++ b/backend/onyx/server/manage/users.py @@ -28,6 +28,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..72b8b216962 100644 --- a/backend/onyx/server/settings/store.py +++ b/backend/onyx/server/settings/store.py @@ -1,21 +1,38 @@ -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.configs import MULTI_TENANT 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()) + if MULTI_TENANT: + # If multi-tenant, anonymous user is always false + anonymous_user_enabled = False + else: + redis_client = get_redis_client(tenant_id=None) + 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 not MULTI_TENANT and settings.anonymous_user_enabled is not None: + # Only non-multi-tenant scenario can set the anonymous user enabled flag + redis_client = get_redis_client(tenant_id=None) + 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 { return (
- - {" "} -
- Forgot Password -
- {isWorking && } - {popup} - { - setIsWorking(true); - try { - await forgotPassword(values.email); - setPopup({ - type: "success", - message: - "Password reset email sent. Please check your inbox.", - }); - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : "An error occurred. Please try again."; - setPopup({ - type: "error", - message: errorMessage, - }); - } finally { - setIsWorking(false); - } - }} - > - {({ isSubmitting }) => ( -
- +
+ Forgot Password +
+ {isWorking && } + {popup} + { + setIsWorking(true); + try { + await forgotPassword(values.email); + setPopup({ + type: "success", + message: "Password reset email sent. Please check your inbox.", + }); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "An error occurred. Please try again."; + setPopup({ + type: "error", + message: errorMessage, + }); + } finally { + setIsWorking(false); + } + }} + > + {({ isSubmitting }) => ( + + -
- -
- - )} -
-
- - - Back to Login - - -
-
+
+ +
+ + )} + +
+ + + Back to Login + + +
); diff --git a/web/src/app/auth/login/EmailPasswordForm.tsx b/web/src/app/auth/login/EmailPasswordForm.tsx index 89829a20543..d6da95c7b62 100644 --- a/web/src/app/auth/login/EmailPasswordForm.tsx +++ b/web/src/app/auth/login/EmailPasswordForm.tsx @@ -12,6 +12,7 @@ import { Spinner } from "@/components/Spinner"; import { set } from "lodash"; import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants"; import Link from "next/link"; +import { useUser } from "@/components/user/UserProvider"; export function EmailPasswordForm({ isSignup = false, @@ -24,6 +25,7 @@ export function EmailPasswordForm({ referralSource?: string; nextUrl?: string | null; }) { + const { user } = useUser(); const { popup, setPopup } = usePopup(); const [isWorking, setIsWorking] = useState(false); return ( @@ -109,24 +111,29 @@ export function EmailPasswordForm({ name="password" label="Password" type="password" + includeForgotPassword={ + NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && !isSignup + } placeholder="**************" /> - {NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && !isSignup && ( - - Forgot Password? - - )} + {user?.is_anonymous_user && ( + + + or continue as guest + + + )} )} diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index 6722f85eb8f..8bb2f6feaf9 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -17,6 +17,8 @@ import { getSecondsUntilExpiration } from "@/lib/time"; import AuthFlowContainer from "@/components/auth/AuthFlowContainer"; import CardSection from "@/components/admin/CardSection"; import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants"; +import { SettingsContext } from "@/components/settings/SettingsProvider"; +import { useContext } from "react"; const Page = async (props: { searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; @@ -43,7 +45,7 @@ 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 @@ -51,12 +53,13 @@ const Page = async (props: { if ( currentUser && currentUser.is_active && + !currentUser.is_anonymous_user && (secondsTillExpiration === null || secondsTillExpiration > 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 @@ -74,67 +77,36 @@ const Page = async (props: { } return ( - -
- -
+
+ +
+ +
-
- {authUrl && authTypeMetadata && ( - <> -

- -

- - - - )} +
+ {authUrl && authTypeMetadata && ( + <> +

+ +

- {authTypeMetadata?.authType === "cloud" && ( -
-
-
- or -
-
- + + + )} -
- - Create an account - + {authTypeMetadata?.authType === "cloud" && ( +
+
+
+ or +
+
+ - {NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && ( - - Reset Password - - )} -
-
- )} - - {authTypeMetadata?.authType === "basic" && ( - -
- - <LoginText /> - -
- -
- - Don't have an account?{" "} +
Create an account - + + {NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && ( + + Reset Password + + )} +
-
- )} -
- + )} + + {authTypeMetadata?.authType === "basic" && ( + <> +
+ + <LoginText /> + +
+ +
+ + )} +
+ +
); }; diff --git a/web/src/app/auth/reset-password/page.tsx b/web/src/app/auth/reset-password/page.tsx index a18cc92f5c4..e96d3051d74 100644 --- a/web/src/app/auth/reset-password/page.tsx +++ b/web/src/app/auth/reset-password/page.tsx @@ -28,87 +28,84 @@ const ResetPasswordPage: React.FC = () => { return (
- -
- Reset Password -
- {isWorking && } - {popup} - { - if (!token) { - setPopup({ - type: "error", - message: "Invalid or missing reset token.", - }); - return; - } - setIsWorking(true); - try { - await resetPassword(token, values.password); - setPopup({ - type: "success", - message: - "Password reset successfully. Redirecting to login...", - }); - setTimeout(() => { - redirect("/auth/login"); - }, 1000); - } catch (error) { - setPopup({ - type: "error", - message: "An error occurred. Please try again.", - }); - } finally { - setIsWorking(false); - } - }} - > - {({ isSubmitting }) => ( -
- - +
+ Reset Password +
+ {isWorking && } + {popup} + { + if (!token) { + setPopup({ + type: "error", + message: "Invalid or missing reset token.", + }); + return; + } + setIsWorking(true); + try { + await resetPassword(token, values.password); + setPopup({ + type: "success", + message: "Password reset successfully. Redirecting to login...", + }); + setTimeout(() => { + redirect("/auth/login"); + }, 1000); + } catch (error) { + setPopup({ + type: "error", + message: "An error occurred. Please try again.", + }); + } finally { + setIsWorking(false); + } + }} + > + {({ isSubmitting }) => ( + + + -
- -
- - )} -
-
- - - Back to Login - - -
-
+
+ +
+ + )} + +
+ + + Back to Login + + +
); diff --git a/web/src/app/auth/signup/page.tsx b/web/src/app/auth/signup/page.tsx index 3e25e36c4c8..9c339c554bc 100644 --- a/web/src/app/auth/signup/page.tsx +++ b/web/src/app/auth/signup/page.tsx @@ -13,7 +13,6 @@ import Link from "next/link"; import { SignInButton } from "../login/SignInButton"; import AuthFlowContainer from "@/components/auth/AuthFlowContainer"; import ReferralSourceSelector from "./ReferralSourceSelector"; -import { Separator } from "@/components/ui/separator"; const Page = async (props: { searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; @@ -39,13 +38,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 (currentUser && currentUser.is_active && !currentUser.is_anonymous_user) { if (!authTypeMetadata?.requiresVerification || currentUser.is_verified) { - return redirect("/"); + return redirect("/chat"); } return redirect("/auth/waiting-on-verification"); } @@ -53,7 +52,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; @@ -62,7 +61,7 @@ const Page = async (props: { } return ( - + <> @@ -95,21 +94,6 @@ const Page = async (props: { shouldVerify={authTypeMetadata?.requiresVerification} nextUrl={nextUrl} /> - -
- - Already have an account?{" "} - - Log In - - -
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..a504c3b82ea 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -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/admin/connectors/Field.tsx b/web/src/components/admin/connectors/Field.tsx index c7032fdd3f1..e91a4a608d0 100644 --- a/web/src/components/admin/connectors/Field.tsx +++ b/web/src/components/admin/connectors/Field.tsx @@ -29,6 +29,7 @@ import { useRef, useState } from "react"; import remarkGfm from "remark-gfm"; import { EditIcon } from "@/components/icons/icons"; import { Button } from "@/components/ui/button"; +import Link from "next/link"; export function SectionHeader({ children, @@ -143,6 +144,7 @@ export function TextFormField({ small, removeLabel, min, + includeForgotPassword, onChange, width, vertical, @@ -169,6 +171,7 @@ export function TextFormField({ explanationLink?: string; small?: boolean; min?: number; + includeForgotPassword?: boolean; onChange?: (e: React.ChangeEvent) => void; width?: string; vertical?: boolean; @@ -238,7 +241,7 @@ export function TextFormField({ )}
{subtext && {subtext}} -
+
+ {includeForgotPassword && ( + + Forgot password? + + )}
{explanationText && ( diff --git a/web/src/components/auth/AuthFlowContainer.tsx b/web/src/components/auth/AuthFlowContainer.tsx index 24832060774..f9fb38eec04 100644 --- a/web/src/components/auth/AuthFlowContainer.tsx +++ b/web/src/components/auth/AuthFlowContainer.tsx @@ -1,16 +1,41 @@ +import Link from "next/link"; import { Logo } from "../logo/Logo"; export default function AuthFlowContainer({ children, + authState, }: { children: React.ReactNode; + authState?: "signup" | "login"; }) { return ( -
-
+
+
{children}
+ {authState === "login" && ( +
+ Don't have an account?{" "} + + Create one + +
+ )} + {authState === "signup" && ( +
+ Already have an account?{" "} + + Log In + +
+ )}
); } diff --git a/web/src/components/chat_search/Header.tsx b/web/src/components/chat_search/Header.tsx index 3050b56dba6..9a452a534d0 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(() => { @@ -106,7 +108,7 @@ export default function FunctionalHeader({
- {setSharingModalVisible && ( + {setSharingModalVisible && !hideUserDropdown && (
setSharingModalVisible(true)} className="mobile:hidden mr-2 my-auto rounded cursor-pointer hover:bg-hover-light" @@ -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 6258b982916..7fddcac6b3e 100644 --- a/web/src/components/icons/icons.tsx +++ b/web/src/components/icons/icons.tsx @@ -2649,7 +2649,7 @@ export const OpenIcon = ({ @@ -2673,7 +2673,7 @@ export const DexpandTwoIcon = ({ @@ -2697,7 +2697,7 @@ export const ExpandTwoIcon = ({ @@ -2721,7 +2721,7 @@ export const DownloadCSVIcon = ({ @@ -2745,7 +2745,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, }; };