Compare commits

..

14 Commits

Author SHA1 Message Date
pablonyx
ab61f9b08b Merge pull request #3357 from onyx-dot-app/anonymous_users
Anonymous users
2024-12-20 17:00:36 -08:00
pablodanswer
9f6eebc2e8 minor clean up 2024-12-20 12:29:06 -08:00
pablodanswer
0bd7782246 quick nits 2024-12-19 20:11:37 -08:00
pablodanswer
8ead244be8 updated logic 2024-12-19 20:09:14 -08:00
pablodanswer
c23fbfe027 cosmetic updates 2024-12-19 19:14:31 -08:00
pablodanswer
fd269983c8 update for multi-tenant clarity 2024-12-19 19:14:20 -08:00
pablodanswer
063440866d add anonymous user 2024-12-19 19:14:19 -08:00
pablodanswer
7933f6500b nit 2024-12-19 18:51:48 -08:00
pablodanswer
e7c52accab nit 2024-12-19 18:09:14 -08:00
pablodanswer
7f74466828 improved config 2024-12-19 15:35:56 -08:00
pablodanswer
bd2538dbd0 forgot password feature 2024-12-19 15:35:55 -08:00
Yuhong Sun
f83e7bfcd9 Fix Default CC Pair (#3513) 2024-12-19 09:43:12 -08:00
pablonyx
4d2e26ce4b MT Cloud Tracking Fix (#3514) 2024-12-19 08:47:02 -08:00
pablonyx
817fdc1f36 Ensure metadata overrides file contents (#3512)
* ensure metadata overrides file contents

* update more blocks
2024-12-19 04:44:24 +00:00
66 changed files with 875 additions and 344 deletions

View File

@@ -66,6 +66,7 @@ jobs:
NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.POSTHOG_HOST }}
NEXT_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}
NEXT_PUBLIC_GTM_ENABLED=true
NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=true
# needed due to weird interactions with the builds for different platforms
no-cache: true
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -10,6 +10,7 @@ logger = setup_logger()
def posthog_on_error(error: Any, items: Any) -> None:
"""Log any PostHog delivery errors."""
logger.error(f"PostHog error: {error}, items: {items}")
@@ -24,15 +25,10 @@ posthog = Posthog(
def event_telemetry(
distinct_id: str, event: str, properties: dict | None = None
) -> None:
logger.info(f"Capturing Posthog event: {distinct_id} {event} {properties}")
print("API KEY", POSTHOG_API_KEY)
print("HOST", POSTHOG_HOST)
"""Capture and send an event to PostHog, flushing immediately."""
logger.info(f"Capturing PostHog event: {distinct_id} {event} {properties}")
try:
print(type(distinct_id))
print(type(event))
print(type(properties))
response = posthog.capture(distinct_id, event, properties)
posthog.capture(distinct_id, event, properties)
posthog.flush()
print(response)
except Exception as e:
logger.error(f"Error capturing Posthog event: {e}")
logger.error(f"Error capturing PostHog event: {e}")

View File

@@ -0,0 +1,80 @@
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from textwrap import dedent
from onyx.configs.app_configs import EMAIL_CONFIGURED
from onyx.configs.app_configs import EMAIL_FROM
from onyx.configs.app_configs import SMTP_PASS
from onyx.configs.app_configs import SMTP_PORT
from onyx.configs.app_configs import SMTP_SERVER
from onyx.configs.app_configs import SMTP_USER
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.models import User
def send_email(
user_email: str,
subject: str,
body: str,
mail_from: str = EMAIL_FROM,
) -> None:
if not EMAIL_CONFIGURED:
raise ValueError("Email is not configured.")
msg = MIMEMultipart()
msg["Subject"] = subject
msg["To"] = user_email
if mail_from:
msg["From"] = mail_from
msg.attach(MIMEText(body))
try:
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as s:
s.starttls()
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
except Exception as e:
raise e
def send_user_email_invite(user_email: str, current_user: User) -> None:
subject = "Invitation to Join Onyx Workspace"
body = dedent(
f"""\
Hello,
You have been invited to join a workspace on Onyx.
To join the workspace, please visit the following link:
{WEB_DOMAIN}/auth/login
Best regards,
The Onyx Team
"""
)
send_email(user_email, subject, body, current_user.email)
def send_forgot_password_email(
user_email: str,
token: str,
mail_from: str = EMAIL_FROM,
) -> None:
subject = "Onyx Forgot Password"
link = f"{WEB_DOMAIN}/auth/reset-password?token={token}"
body = f"Click the following link to reset your password: {link}"
send_email(user_email, subject, body, mail_from)
def send_user_verification_email(
user_email: str,
token: str,
mail_from: str = EMAIL_FROM,
) -> None:
subject = "Onyx Email Verification"
link = f"{WEB_DOMAIN}/auth/verify-email?token={token}"
body = f"Click the following link to verify your email address: {link}"
send_email(user_email, subject, body, mail_from)

View File

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

View File

@@ -1,10 +1,7 @@
import smtplib
import uuid
from collections.abc import AsyncGenerator
from datetime import datetime
from datetime import timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import cast
from typing import Dict
from typing import List
@@ -53,19 +50,17 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from onyx.auth.api_key import get_hashed_api_key_from_request
from onyx.auth.email_utils import send_forgot_password_email
from onyx.auth.email_utils import send_user_verification_email
from onyx.auth.invited_users import get_invited_users
from onyx.auth.schemas import UserCreate
from onyx.auth.schemas import UserRole
from onyx.auth.schemas import UserUpdate
from onyx.configs.app_configs import AUTH_TYPE
from onyx.configs.app_configs import DISABLE_AUTH
from onyx.configs.app_configs import EMAIL_FROM
from onyx.configs.app_configs import EMAIL_CONFIGURED
from onyx.configs.app_configs import REQUIRE_EMAIL_VERIFICATION
from onyx.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
from onyx.configs.app_configs import SMTP_PASS
from onyx.configs.app_configs import SMTP_PORT
from onyx.configs.app_configs import SMTP_SERVER
from onyx.configs.app_configs import SMTP_USER
from onyx.configs.app_configs import TRACK_EXTERNAL_IDP_EXPIRY
from onyx.configs.app_configs import USER_AUTH_SECRET
from onyx.configs.app_configs import VALID_EMAIL_DOMAINS
@@ -74,6 +69,7 @@ from onyx.configs.constants import AuthType
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
@@ -89,7 +85,7 @@ from onyx.db.models import AccessToken
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
@@ -103,6 +99,11 @@ from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
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
@@ -143,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:
@@ -193,30 +208,6 @@ def verify_email_domain(email: str) -> None:
)
def send_user_verification_email(
user_email: str,
token: str,
mail_from: str = EMAIL_FROM,
) -> None:
msg = MIMEMultipart()
msg["Subject"] = "Onyx Email Verification"
msg["To"] = user_email
if mail_from:
msg["From"] = mail_from
link = f"{WEB_DOMAIN}/auth/verify-email?token={token}"
body = MIMEText(f"Click the following link to verify your email address: {link}")
msg.attach(body)
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as s:
s.starttls()
# If credentials fails with gmail, check (You need an app password, not just the basic email password)
# https://support.google.com/accounts/answer/185833?sjid=8512343437447396151-NA
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = USER_AUTH_SECRET
verification_token_secret = USER_AUTH_SECRET
@@ -506,7 +497,15 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
async def on_after_forgot_password(
self, user: User, token: str, request: Optional[Request] = None
) -> None:
logger.notice(f"User {user.id} has forgot their password. Reset token: {token}")
if not EMAIL_CONFIGURED:
logger.error(
"Email is not configured. Please configure email in the admin panel"
)
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
"Your admin has not enbaled this feature.",
)
send_forgot_password_email(user.email, token)
async def on_after_request_verify(
self, user: User, token: str, request: Optional[Request] = None
@@ -624,9 +623,7 @@ def get_database_strategy(
auth_backend = AuthenticationBackend(
name="jwt" if MULTI_TENANT else "database",
transport=cookie_transport,
get_strategy=get_jwt_strategy if MULTI_TENANT else get_database_strategy, # type: ignore
name="jwt", transport=cookie_transport, get_strategy=get_jwt_strategy
) # type: ignore
@@ -713,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 user
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.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
if allow_anonymous_access:
return None
if user is None:
raise BasicAuthenticationError(
detail="Access denied. User is not authenticated.",
)
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.",
)
return user
raise BasicAuthenticationError(
detail="Access denied. User is not authenticated.",
)
async def current_user_with_expired_token(
@@ -751,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:

View File

@@ -92,6 +92,7 @@ SMTP_SERVER = os.environ.get("SMTP_SERVER") or "smtp.gmail.com"
SMTP_PORT = int(os.environ.get("SMTP_PORT") or "587")
SMTP_USER = os.environ.get("SMTP_USER", "your-email@gmail.com")
SMTP_PASS = os.environ.get("SMTP_PASS", "your-gmail-password")
EMAIL_CONFIGURED = all([SMTP_SERVER, SMTP_USER, SMTP_PASS])
EMAIL_FROM = os.environ.get("EMAIL_FROM") or SMTP_USER
# If set, Onyx will listen to the `expires_at` returned by the identity

View File

@@ -36,6 +36,8 @@ DISABLED_GEN_AI_MSG = (
DEFAULT_PERSONA_ID = 0
DEFAULT_CC_PAIR_ID = 1
# Postgres connection constants for application_name
POSTGRES_WEB_APP_NAME = "web"
POSTGRES_INDEXER_APP_NAME = "indexer"
@@ -273,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:

View File

@@ -310,6 +310,9 @@ def associate_default_cc_pair(db_session: Session) -> None:
if existing_association is not None:
return
# DefaultCCPair has id 1 since it is the first CC pair created
# It is DEFAULT_CC_PAIR_ID, but can't set it explicitly because it messed with the
# auto-incrementing id
association = ConnectorCredentialPair(
connector_id=0,
credential_id=0,

View File

@@ -55,9 +55,7 @@ def remove_invalid_unicode_chars(text: str) -> str:
return _illegal_xml_chars_RE.sub("", text)
def get_vespa_http_client(
no_timeout: bool = False, http2: bool = False
) -> httpx.Client:
def get_vespa_http_client(no_timeout: bool = False, http2: bool = True) -> httpx.Client:
"""
Configure and return an HTTP client for communicating with Vespa,
including authentication if needed.

View File

@@ -5,6 +5,7 @@ from fastapi.dependencies.models import Dependant
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
):

View File

@@ -532,7 +532,8 @@ def associate_credential_to_connector(
)
return response
except IntegrityError:
except IntegrityError as e:
logger.error(f"IntegrityError: {e}")
raise HTTPException(status_code=400, detail="Name must be unique")

View File

@@ -14,6 +14,7 @@ from pydantic import BaseModel
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)

View File

@@ -10,6 +10,7 @@ from pydantic import BaseModel
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),

View File

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

View File

@@ -7,7 +7,7 @@ from fastapi import Query
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 [

View File

@@ -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 @@ class UserInfo(BaseModel):
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 @@ class UserInfo(BaseModel):
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,
)

View File

@@ -21,12 +21,14 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ee.onyx.configs.app_configs import SUPER_USERS
from onyx.auth.email_utils import send_user_email_invite
from onyx.auth.invited_users import get_invited_users
from onyx.auth.invited_users import write_invited_users
from onyx.auth.noauth_user import fetch_no_auth_user
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
@@ -61,7 +63,6 @@ from onyx.server.models import FullUserSnapshot
from onyx.server.models import InvitedUserSnapshot
from onyx.server.models import MinimalUserSnapshot
from onyx.server.utils import BasicAuthenticationError
from onyx.server.utils import send_user_email_invite
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from shared_configs.configs import MULTI_TENANT
@@ -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):

View File

@@ -4,6 +4,7 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import api_key_dep
from onyx.configs.constants import DEFAULT_CC_PAIR_ID
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import IndexAttemptMetadata
@@ -79,7 +80,7 @@ def upsert_ingestion_doc(
document.source = DocumentSource.FILE
cc_pair = get_connector_credential_pair_from_id(
cc_pair_id=doc_info.cc_pair_id or 0, db_session=db_session
cc_pair_id=doc_info.cc_pair_id or DEFAULT_CC_PAIR_ID, db_session=db_session
)
if cc_pair is None:
raise HTTPException(

View File

@@ -19,6 +19,7 @@ from PIL import Image
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 @@ async def is_connected(request: Request) -> Callable[[], 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),

View File

@@ -11,7 +11,7 @@ from sqlalchemy import func
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 @@ TOKEN_BUDGET_UNIT = 1_000
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

View File

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

View File

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

View File

@@ -1,21 +1,10 @@
import json
import smtplib
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from textwrap import dedent
from typing import Any
from fastapi import HTTPException
from fastapi import status
from onyx.configs.app_configs import SMTP_PASS
from onyx.configs.app_configs import SMTP_PORT
from onyx.configs.app_configs import SMTP_SERVER
from onyx.configs.app_configs import SMTP_USER
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.models import User
class BasicAuthenticationError(HTTPException):
def __init__(self, detail: str):
@@ -62,31 +51,3 @@ def mask_credential_dict(credential_dict: dict[str, Any]) -> dict[str, str]:
masked_creds[key] = mask_string(val)
return masked_creds
def send_user_email_invite(user_email: str, current_user: User) -> None:
msg = MIMEMultipart()
msg["Subject"] = "Invitation to Join Onyx Workspace"
msg["From"] = current_user.email
msg["To"] = user_email
email_body = dedent(
f"""\
Hello,
You have been invited to join a workspace on Onyx.
To join the workspace, please visit the following link:
{WEB_DOMAIN}/auth/login
Best regards,
The Onyx Team
"""
)
msg.attach(MIMEText(email_body, "plain"))
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as smtp_server:
smtp_server.starttls()
smtp_server.login(SMTP_USER, SMTP_PASS)
smtp_server.send_message(msg)

View File

@@ -22,7 +22,6 @@ from onyx.utils.variable_functionality import (
from onyx.utils.variable_functionality import noop_fallback
from shared_configs.configs import MULTI_TENANT
_DANSWER_TELEMETRY_ENDPOINT = "https://telemetry.onyx.app/anonymous_telemetry"
_CACHED_UUID: str | None = None
_CACHED_INSTANCE_DOMAIN: str | None = None
@@ -118,12 +117,9 @@ def mt_cloud_telemetry(
event: MilestoneRecordType,
properties: dict | None = None,
) -> None:
print(f"mt_cloud_telemetry {distinct_id} {event} {properties}")
if not MULTI_TENANT:
print("mt_cloud_telemetry not MULTI_TENANT")
return
print("mt_cloud_telemetry MULTI_TENANT")
# MIT version should not need to include any Posthog code
# This is only for Onyx MT Cloud, this code should also never be hit, no reason for any orgs to
# be running the Multi Tenant version of Onyx.
@@ -141,11 +137,8 @@ def create_milestone_and_report(
properties: dict | None,
db_session: Session,
) -> None:
print(f"create_milestone_and_report {user} {event_type} {db_session}")
_, is_new = create_milestone_if_not_exists(user, event_type, db_session)
print(f"create_milestone_and_report {is_new}")
if is_new:
print("create_milestone_and_report is_new")
mt_cloud_telemetry(
distinct_id=distinct_id,
event=event_type,

View File

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

View File

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

View File

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

View File

@@ -267,7 +267,7 @@ services:
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN=${NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN:-}
- NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=${NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED:-}
# Enterprise Edition only
- NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-}
# DO NOT TURN ON unless you have EXPLICIT PERMISSION from Onyx.

View File

@@ -72,6 +72,7 @@ services:
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-}
- NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=${NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED:-}
depends_on:
- api_server
restart: always

View File

@@ -99,6 +99,7 @@ services:
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-}
- NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=${NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED:-}
depends_on:
- api_server
restart: always

View File

@@ -75,6 +75,9 @@ ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
ARG NEXT_PUBLIC_GTM_ENABLED
ENV NEXT_PUBLIC_GTM_ENABLED=${NEXT_PUBLIC_GTM_ENABLED}
ARG NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED
ENV NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=${NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED}
RUN npx next build
# Step 2. Production image, copy all the files and run next
@@ -150,6 +153,9 @@ ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
ARG NEXT_PUBLIC_GTM_ENABLED
ENV NEXT_PUBLIC_GTM_ENABLED=${NEXT_PUBLIC_GTM_ENABLED}
ARG NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED
ENV NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=${NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED}
# Note: Don't expose ports here, Compose will handle that for us if necessary.
# If you want to run this without compose, specify the ports to
# expose via cli

View File

@@ -81,13 +81,13 @@ export const getProviderIcon = (providerName: string, modelName?: string) => {
}
if (modelName?.toLowerCase().includes("phi")) {
return MicrosoftIconSVG;
}
}
if (modelName?.toLowerCase().includes("mistral")) {
return MistralIcon;
}
}
if (modelName?.toLowerCase().includes("llama")) {
return MetaIcon;
}
}
if (modelName?.toLowerCase().includes("gemini")) {
return GeminiIcon;
}

View File

@@ -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<Settings | null>(null);
const [chatRetention, setChatRetention] = useState("");
const { popup, setPopup } = usePopup();
@@ -171,11 +173,22 @@ export function SettingsForm() {
fieldName: keyof Settings,
checked: boolean
) {
const updates: { fieldName: keyof Settings; newValue: any }[] = [
{ fieldName, newValue: checked },
];
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: "anonymous_user_enabled", newValue: true },
];
updateSettingField(updates);
setShowConfirmModal(false);
}
function handleSetChatRetention() {
@@ -205,10 +218,41 @@ export function SettingsForm() {
handleToggleSettingsField("auto_scroll", e.target.checked)
}
/>
<Checkbox
label="Anonymous Users"
sublabel="If set, users will not be required to sign in to use Danswer."
checked={settings.anonymous_user_enabled}
onChange={(e) =>
handleToggleSettingsField("anonymous_user_enabled", e.target.checked)
}
/>
{showConfirmModal && (
<Modal
width="max-w-3xl w-full"
onOutsideClick={() => setShowConfirmModal(false)}
>
<div className="flex flex-col gap-4">
<h2 className="text-xl font-bold">Enable Anonymous Users</h2>
<p>
Are you sure you want to enable anonymous users? This will allow
anyone to use Danswer without signing in.
</p>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setShowConfirmModal(false)}
>
Cancel
</Button>
<Button onClick={handleConfirmAnonymousUsers}>Confirm</Button>
</div>
</div>
</Modal>
)}
{isEnterpriseEnabled && (
<>
<Title className="mb-4">Chat Settings</Title>
<Title className="mt-8 mb-4">Chat Settings</Title>
<IntegerInput
label="Chat Retention"
sublabel="Enter the maximum number of days you would like Onyx to retain chat messages. Leaving this field empty will cause Onyx to never delete chat messages."

View File

@@ -5,6 +5,7 @@ export enum GatingType {
}
export interface Settings {
anonymous_user_enabled: boolean;
maximum_chat_retention_days: number | null;
notifications: Notification[];
needs_reindexing: boolean;

View File

@@ -0,0 +1,96 @@
"use client";
import React, { useState } from "react";
import { forgotPassword } from "./utils";
import AuthFlowContainer from "@/components/auth/AuthFlowContainer";
import CardSection from "@/components/admin/CardSection";
import Title from "@/components/ui/title";
import Text from "@/components/ui/text";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { TextFormField } from "@/components/admin/connectors/Field";
import { usePopup } from "@/components/admin/connectors/Popup";
import { Spinner } from "@/components/Spinner";
import { redirect } from "next/navigation";
import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants";
const ForgotPasswordPage: React.FC = () => {
const { popup, setPopup } = usePopup();
const [isWorking, setIsWorking] = useState(false);
if (!NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED) {
redirect("/auth/login");
}
return (
<AuthFlowContainer>
<div className="flex flex-col w-full justify-center">
<div className="flex">
<Title className="mb-2 mx-auto font-bold">Forgot Password</Title>
</div>
{isWorking && <Spinner />}
{popup}
<Formik
initialValues={{
email: "",
}}
validationSchema={Yup.object().shape({
email: Yup.string().email().required(),
})}
onSubmit={async (values) => {
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 }) => (
<Form className="w-full flex flex-col items-stretch mt-2">
<TextFormField
name="email"
label="Email"
type="email"
placeholder="email@yourcompany.com"
/>
<div className="flex">
<Button
type="submit"
disabled={isSubmitting}
className="mx-auto w-full"
>
Reset Password
</Button>
</div>
</Form>
)}
</Formik>
<div className="flex">
<Text className="mt-4 mx-auto">
<Link href="/auth/login" className="text-link font-medium">
Back to Login
</Link>
</Text>
</div>
</div>
</AuthFlowContainer>
);
};
export default ForgotPasswordPage;

View File

@@ -0,0 +1,33 @@
export const forgotPassword = async (email: string): Promise<void> => {
const response = await fetch(`/api/auth/forgot-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const error = await response.json();
const errorMessage =
error?.detail || "An error occurred during password reset.";
throw new Error(errorMessage);
}
};
export const resetPassword = async (
token: string,
password: string
): Promise<void> => {
const response = await fetch(`/api/auth/reset-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token, password }),
});
if (!response.ok) {
throw new Error("Failed to reset password");
}
};

View File

@@ -10,6 +10,9 @@ import { requestEmailVerification } from "../lib";
import { useState } from "react";
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,
@@ -22,6 +25,7 @@ export function EmailPasswordForm({
referralSource?: string;
nextUrl?: string | null;
}) {
const { user } = useUser();
const { popup, setPopup } = usePopup();
const [isWorking, setIsWorking] = useState(false);
return (
@@ -107,18 +111,29 @@ export function EmailPasswordForm({
name="password"
label="Password"
type="password"
includeForgotPassword={
NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && !isSignup
}
placeholder="**************"
/>
<div className="flex">
<Button
type="submit"
disabled={isSubmitting}
className="mx-auto w-full"
<Button
type="submit"
disabled={isSubmitting}
className="mx-auto !py-4 w-full"
>
{isSignup ? "Sign Up" : "Log In"}
</Button>
{user?.is_anonymous_user && (
<Link
href="/chat"
className="text-xs text-blue-500 cursor-pointer text-center w-full text-link font-medium mx-auto"
>
{isSignup ? "Sign Up" : "Log In"}
</Button>
</div>
<span className="hover:border-b hover:border-dotted hover:border-blue-500">
or continue as guest
</span>
</Link>
)}
</Form>
)}
</Formik>

View File

@@ -16,6 +16,9 @@ import { LoginText } from "./LoginText";
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 }>;
@@ -42,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
@@ -50,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
@@ -73,71 +77,71 @@ const Page = async (props: {
}
return (
<AuthFlowContainer>
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<div className="flex flex-col ">
<AuthFlowContainer authState="login">
<div className="absolute top-10x w-full">
<HealthCheckBanner />
</div>
<div className="flex flex-col w-full justify-center">
{authUrl && authTypeMetadata && (
<>
<h2 className="text-center text-xl text-strong font-bold">
<LoginText />
</h2>
<SignInButton
authorizeUrl={authUrl}
authType={authTypeMetadata?.authType}
/>
</>
)}
{authTypeMetadata?.authType === "cloud" && (
<div className="mt-4 w-full justify-center">
<div className="flex items-center w-full my-4">
<div className="flex-grow border-t border-gray-300"></div>
<span className="px-4 text-gray-500">or</span>
<div className="flex-grow border-t border-gray-300"></div>
</div>
<EmailPasswordForm shouldVerify={true} nextUrl={nextUrl} />
<div className="flex">
<Text className="mt-4 mx-auto">
Don&apos;t have an account?{" "}
<Link
href={`/auth/signup${searchParams?.next ? `?next=${searchParams.next}` : ""}`}
className="text-link font-medium"
>
Create an account
</Link>
</Text>
</div>
</div>
)}
{authTypeMetadata?.authType === "basic" && (
<CardSection className="mt-4 w-96">
<div className="flex">
<Title className="mb-2 mx-auto font-bold">
<div className="flex flex-col w-full justify-center">
{authUrl && authTypeMetadata && (
<>
<h2 className="text-center text-xl text-strong font-bold">
<LoginText />
</Title>
</div>
<EmailPasswordForm nextUrl={nextUrl} />
<div className="flex">
<Text className="mt-4 mx-auto">
Don&apos;t have an account?{" "}
</h2>
<SignInButton
authorizeUrl={authUrl}
authType={authTypeMetadata?.authType}
/>
</>
)}
{authTypeMetadata?.authType === "cloud" && (
<div className="mt-4 w-full justify-center">
<div className="flex items-center w-full my-4">
<div className="flex-grow border-t border-gray-300"></div>
<span className="px-4 text-gray-500">or</span>
<div className="flex-grow border-t border-gray-300"></div>
</div>
<EmailPasswordForm shouldVerify={true} nextUrl={nextUrl} />
<div className="flex mt-4 justify-between">
<Link
href={`/auth/signup${searchParams?.next ? `?next=${searchParams.next}` : ""}`}
href={`/auth/signup${
searchParams?.next ? `?next=${searchParams.next}` : ""
}`}
className="text-link font-medium"
>
Create an account
</Link>
</Text>
{NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && (
<Link
href="/auth/forgot-password"
className="text-link font-medium"
>
Reset Password
</Link>
)}
</div>
</div>
</CardSection>
)}
</div>
</AuthFlowContainer>
)}
{authTypeMetadata?.authType === "basic" && (
<>
<div className="flex">
<Title className="mb-2 mx-auto text-xl text-strong font-bold">
<LoginText />
</Title>
</div>
<EmailPasswordForm nextUrl={nextUrl} />
<div className="flex flex-col gap-y-2 items-center"></div>
</>
)}
</div>
</AuthFlowContainer>
</div>
);
};

View File

@@ -0,0 +1,114 @@
"use client";
import React, { useState } from "react";
import { resetPassword } from "../forgot-password/utils";
import AuthFlowContainer from "@/components/auth/AuthFlowContainer";
import CardSection from "@/components/admin/CardSection";
import Title from "@/components/ui/title";
import Text from "@/components/ui/text";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { TextFormField } from "@/components/admin/connectors/Field";
import { usePopup } from "@/components/admin/connectors/Popup";
import { Spinner } from "@/components/Spinner";
import { redirect, useSearchParams } from "next/navigation";
import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants";
const ResetPasswordPage: React.FC = () => {
const { popup, setPopup } = usePopup();
const [isWorking, setIsWorking] = useState(false);
const searchParams = useSearchParams();
const token = searchParams.get("token");
if (!NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED) {
redirect("/auth/login");
}
return (
<AuthFlowContainer>
<div className="flex flex-col w-full justify-center">
<div className="flex">
<Title className="mb-2 mx-auto font-bold">Reset Password</Title>
</div>
{isWorking && <Spinner />}
{popup}
<Formik
initialValues={{
password: "",
confirmPassword: "",
}}
validationSchema={Yup.object().shape({
password: Yup.string().required("Password is required"),
confirmPassword: Yup.string()
.oneOf([Yup.ref("password"), undefined], "Passwords must match")
.required("Confirm Password is required"),
})}
onSubmit={async (values) => {
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 }) => (
<Form className="w-full flex flex-col items-stretch mt-2">
<TextFormField
name="password"
label="New Password"
type="password"
placeholder="Enter your new password"
/>
<TextFormField
name="confirmPassword"
label="Confirm New Password"
type="password"
placeholder="Confirm your new password"
/>
<div className="flex">
<Button
type="submit"
disabled={isSubmitting}
className="mx-auto w-full"
>
Reset Password
</Button>
</div>
</Form>
)}
</Formik>
<div className="flex">
<Text className="mt-4 mx-auto">
<Link href="/auth/login" className="text-link font-medium">
Back to Login
</Link>
</Text>
</div>
</div>
</AuthFlowContainer>
);
};
export default ResetPasswordPage;

View File

@@ -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 (
<AuthFlowContainer>
<AuthFlowContainer authState="signup">
<HealthCheckBanner />
<>
@@ -95,21 +94,6 @@ const Page = async (props: {
shouldVerify={authTypeMetadata?.requiresVerification}
nextUrl={nextUrl}
/>
<div className="flex">
<Text className="mt-4 mx-auto">
Already have an account?{" "}
<Link
href={{
pathname: "/auth/login",
query: { ...searchParams },
}}
className="text-link font-medium"
>
Log In
</Link>
</Text>
</div>
</div>
</>
</AuthFlowContainer>

View File

@@ -23,7 +23,7 @@ export default async function Page() {
}
if (!authTypeMetadata?.requiresVerification || currentUser?.is_verified) {
return redirect("/");
return redirect("/chat");
}
return <Verify user={currentUser} />;

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBad
import { MetadataBadge } from "@/components/MetadataBadge";
import { WebResultIcon } from "@/components/WebResultIcon";
import { Dispatch, SetStateAction } from "react";
import { ValidSources } from "@/lib/types";
import { openDocument } from "@/lib/search/utils";
interface DocumentDisplayProps {
closeSidebar: () => void;
@@ -73,14 +73,6 @@ export function ChatDocumentDisplay({
return null;
}
const handleViewFile = async () => {
if (document.source_type == ValidSources.File && setPresentingDocument) {
setPresentingDocument(document);
} else if (document.link) {
window.open(document.link, "_blank");
}
};
const hasMetadata =
document.updated_at || Object.keys(document.metadata).length > 0;
return (
@@ -91,7 +83,7 @@ export function ChatDocumentDisplay({
}`}
>
<button
onClick={handleViewFile}
onClick={() => openDocument(document, setPresentingDocument)}
className="cursor-pointer text-left flex flex-col px-2 py-1.5"
>
<div className="line-clamp-1 mb-1 flex h-6 items-center gap-2 text-xs">

View File

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

View File

@@ -156,6 +156,7 @@ export default async function RootLayout({
</div>
);
}
if (productGating === GatingType.FULL) {
return getPageContent(
<div className="flex flex-col items-center justify-center min-h-screen">

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function NotFound() {
redirect("/chat");
redirect("/auth/login");
}

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default async function Page() {
redirect("/chat");
redirect("/auth/login");
}

View File

@@ -59,9 +59,11 @@ const DropdownOption: React.FC<DropdownOptionProps> = ({
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 ? (
<DropdownOption
onClick={() => router.push("/auth/login")}
icon={<UserIcon className="h-5 w-5 my-auto mr-2" />}
label="Log In"
/>
) : (
<>
{customNavItems.map((item, i) => (
@@ -251,6 +260,7 @@ export function UserDropdown({
label="User Settings"
/>
)}
<DropdownOption
onClick={() => {
setUserInfoVisible(true);

View File

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

View File

@@ -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<HTMLInputElement>) => void;
width?: string;
vertical?: boolean;
@@ -211,7 +214,11 @@ export function TextFormField({
return (
<div className={`w-full ${width}`}>
<div className={`flex ${vertical ? "flex-col" : "flex-row"} items-start`}>
<div
className={`flex ${
vertical ? "flex-col" : "flex-row"
} gap-x-2 items-start`}
>
<div className="flex gap-x-2 items-center">
{!removeLabel && (
<Label className={sizeClass.label} small={small}>
@@ -234,7 +241,7 @@ export function TextFormField({
)}
</div>
{subtext && <SubLabel>{subtext}</SubLabel>}
<div className={`w-full flex ${includeRevert && "gap-x-2"}`}>
<div className={`w-full flex ${includeRevert && "gap-x-2"} relative`}>
<Field
onChange={handleChange}
min={min}
@@ -265,6 +272,14 @@ export function TextFormField({
placeholder={placeholder}
autoComplete={autoCompleteDisabled ? "off" : undefined}
/>
{includeForgotPassword && (
<Link
href="/auth/forgot-password"
className="absolute right-3 top-1/2 mt-[3px] transform -translate-y-1/2 text-xs text-blue-500 cursor-pointer"
>
Forgot password?
</Link>
)}
</div>
{explanationText && (

View File

@@ -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 (
<div className="flex flex-col items-center justify-center min-h-screen bg-background">
<div className="w-full max-w-md bg-black pt-8 pb-4 px-8 mx-4 gap-y-4 bg-white flex items-center flex-col rounded-xl shadow-lg border border-bacgkround-100">
<div className="p-4 flex flex-col items-center justify-center min-h-screen bg-background">
<div className="w-full max-w-md bg-black pt-8 pb-6 px-8 mx-4 gap-y-4 bg-white flex items-center flex-col rounded-xl shadow-lg border border-bacgkround-100">
<Logo width={70} height={70} />
{children}
</div>
{authState === "login" && (
<div className="text-sm mt-4 text-center w-full text-neutral-900 font-medium mx-auto">
Don&apos;t have an account?{" "}
<Link
href="/auth/signup"
className=" underline transition-colors duration-200"
>
Create one
</Link>
</div>
)}
{authState === "signup" && (
<div className="text-sm mt-4 text-center w-full text-neutral-900 font-medium mx-auto">
Already have an account?{" "}
<Link
href="/auth/login"
className=" underline transition-colors duration-200"
>
Log In
</Link>
</div>
)}
</div>
);
}

View File

@@ -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<boolean>) => void;
toggleSidebar?: () => void;
toggleUserSettings?: () => void;
hideUserDropdown?: boolean;
}) {
const settings = useContext(SettingsContext);
useEffect(() => {
@@ -106,7 +108,7 @@ export default function FunctionalHeader({
</div>
<div className="absolute right-0 mobile:top-2 desktop:top-0 flex">
{setSharingModalVisible && (
{setSharingModalVisible && !hideUserDropdown && (
<div
onClick={() => setSharingModalVisible(true)}
className="mobile:hidden mr-2 my-auto rounded cursor-pointer hover:bg-hover-light"
@@ -114,8 +116,10 @@ export default function FunctionalHeader({
<FiShare2 size="18" />
</div>
)}
<div className="mobile:hidden flex my-auto">
<UserDropdown
hideUserDropdown={hideUserDropdown}
page={page}
toggleUserSettings={toggleUserSettings}
/>

View File

@@ -7,6 +7,7 @@ interface UseSidebarVisibilityProps {
setShowDocSidebar: Dispatch<SetStateAction<boolean>>;
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;

View File

@@ -2,9 +2,7 @@ import { WebResultIcon } from "@/components/WebResultIcon";
import { SourceIcon } from "@/components/SourceIcon";
import { OnyxDocument } from "@/lib/search/interfaces";
import { truncateString } from "@/lib/utils";
import { SetStateAction } from "react";
import { Dispatch } from "react";
import { ValidSources } from "@/lib/types";
import { openDocument } from "@/lib/search/utils";
export default function SourceCard({
doc,
@@ -16,13 +14,7 @@ export default function SourceCard({
return (
<div
key={doc.document_id}
onClick={() => {
if (doc.source_type == ValidSources.File && setPresentingDocument) {
setPresentingDocument(doc);
} else if (doc.link) {
window.open(doc.link, "_blank");
}
}}
onClick={() => openDocument(doc, setPresentingDocument)}
className="cursor-pointer text-left overflow-hidden flex flex-col gap-0.5 rounded-sm px-3 py-2.5 hover:bg-background-125 bg-background-100 w-[200px]"
>
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center gap-2 text-sm">

View File

@@ -1121,12 +1121,16 @@ export const MetaIcon = ({
export const MicrosoftIconSVG = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => <LogoIcon size={size} className={className} src={microsoftSVG} />;
}: IconProps) => (
<LogoIcon size={size} className={className} src={microsoftSVG} />
);
export const MistralIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => <LogoIcon size={size} className={className} src={mistralSVG} />;
}: IconProps) => (
<LogoIcon size={size} className={className} src={mistralSVG} />
);
export const VoyageIcon = ({
size = 16,
@@ -2645,7 +2649,7 @@ export const OpenIcon = ({
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
strokeLinecap="round"
stroke-linejoin="round"
d="M7 13.5a9.26 9.26 0 0 0-5.61-2.95a1 1 0 0 1-.89-1V1.5A1 1 0 0 1 1.64.51A9.3 9.3 0 0 1 7 3.43zm0 0a9.26 9.26 0 0 1 5.61-2.95a1 1 0 0 0 .89-1V1.5a1 1 0 0 0-1.14-.99A9.3 9.3 0 0 0 7 3.43z"
/>
@@ -2669,7 +2673,7 @@ export const DexpandTwoIcon = ({
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
strokeLinecap="round"
stroke-linejoin="round"
d="m.5 13.5l5-5m-4 0h4v4m8-12l-5 5m4 0h-4v-4"
/>
@@ -2693,7 +2697,7 @@ export const ExpandTwoIcon = ({
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
strokeLinecap="round"
stroke-linejoin="round"
d="m8.5 5.5l5-5m-4 0h4v4m-8 4l-5 5m4 0h-4v-4"
/>
@@ -2717,7 +2721,7 @@ export const DownloadCSVIcon = ({
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
strokeLinecap="round"
stroke-linejoin="round"
d="M.5 10.5v1a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1M4 6l3 3.5L10 6M7 9.5v-9"
/>
@@ -2741,7 +2745,7 @@ export const UserIcon = ({
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
strokeLinecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19.618 21.25c0-3.602-4.016-6.53-7.618-6.53c-3.602 0-7.618 2.928-7.618 6.53M12 11.456a4.353 4.353 0 1 0 0-8.706a4.353 4.353 0 0 0 0 8.706"

View File

@@ -22,6 +22,7 @@ import { WarningCircle } from "@phosphor-icons/react";
import TextView from "../chat_search/TextView";
import { SearchResultIcon } from "../SearchResultIcon";
import { ValidSources } from "@/lib/types";
import { openDocument } from "@/lib/search/utils";
export const buildDocumentSummaryDisplay = (
matchHighlights: string[],
@@ -428,19 +429,15 @@ export function CompactDocumentCard({
url,
updatePresentingDocument,
}: {
document: LoadedOnyxDocument;
document: OnyxDocument;
icon?: React.ReactNode;
url?: string;
updatePresentingDocument: (documentIndex: LoadedOnyxDocument) => void;
updatePresentingDocument: (document: OnyxDocument) => void;
}) {
return (
<div
onClick={() => {
if (document.source_type === ValidSources.File) {
updatePresentingDocument(document);
} else if (document.link) {
window.open(document.link, "_blank");
}
openDocument(document, updatePresentingDocument);
}}
className="max-w-[250px] cursor-pointer pb-0 pt-0 mt-0 flex gap-y-0 flex-col content-start items-start gap-0 "
>

View File

@@ -1,6 +1,6 @@
import { ReactNode } from "react";
import { CompactDocumentCard } from "../DocumentDisplay";
import { LoadedOnyxDocument } from "@/lib/search/interfaces";
import { LoadedOnyxDocument, OnyxDocument } from "@/lib/search/interfaces";
import {
Tooltip,
TooltipContent,
@@ -8,6 +8,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ValidSources } from "@/lib/types";
import { openDocument } from "@/lib/search/utils";
export function Citation({
children,
@@ -21,7 +22,7 @@ export function Citation({
link?: string;
children?: JSX.Element | string | null | ReactNode;
index?: number;
updatePresentingDocument: (documentIndex: LoadedOnyxDocument) => void;
updatePresentingDocument: (document: OnyxDocument) => void;
document: LoadedOnyxDocument;
icon?: React.ReactNode;
url?: string;
@@ -30,20 +31,12 @@ export function Citation({
? children?.toString().split("[")[1].split("]")[0]
: index;
const onClick = () => {
if (document.source_type == ValidSources.File) {
updatePresentingDocument(document);
} else {
window.open(link || document.link, "_blank");
}
};
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div
onMouseDown={onClick}
onClick={() => openDocument(document, updatePresentingDocument)}
className="inline-flex items-center cursor-pointer transition-all duration-200 ease-in-out"
>
<span className="flex items-center justify-center w-6 h-6 text-[11px] font-medium text-gray-700 bg-gray-100 rounded-full border border-gray-300 hover:bg-gray-200 hover:text-gray-900 shadow-sm">

View File

@@ -49,10 +49,13 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
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<CombinedSettings | null> {
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<CombinedSettings | null> {
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 {

View File

@@ -2,34 +2,15 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
isEditing?: boolean;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, isEditing = true, style, ...props }, ref) => {
const textClassName = "text-2xl text-strong dark:text-neutral-50";
if (!isEditing) {
return (
<span className={cn(textClassName, className)}>
{props.value || props.defaultValue}
</span>
);
}
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
textClassName,
"w-[1ch] min-w-[1ch] box-content pr-1",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
style={{
width: `${Math.max(1, String(props.value || props.defaultValue || "").length)}ch`,
...style,
}}
ref={ref}
{...props}
/>

View File

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

View File

@@ -74,6 +74,12 @@ export const NEXT_PUBLIC_CLOUD_ENABLED =
export const REGISTRATION_URL =
process.env.INTERNAL_URL || "http://127.0.0.1:3001";
export const SERVER_SIDE_ONLY__CLOUD_ENABLED =
process.env.NEXT_PUBLIC_CLOUD_ENABLED?.toLowerCase() === "true";
export const NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED =
process.env.NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED?.toLowerCase() === "true";
export const TEST_ENV = process.env.TEST_ENV?.toLowerCase() === "true";
export const NEXT_PUBLIC_DELETE_ALL_CHATS_ENABLED =

View File

@@ -101,7 +101,7 @@ const MODEL_NAMES_SUPPORTING_IMAGE_INPUT = [
"amazon.nova-pro@v1",
// meta models
"llama-3.2-90b-vision-instruct",
"llama-3.2-11b-vision-instruct"
"llama-3.2-11b-vision-instruct",
];
export function checkLLMSupportsImageInput(model: string) {

View File

@@ -1,5 +1,10 @@
import { Tag } from "../types";
import { Filters, SourceMetadata } from "./interfaces";
import { Tag, ValidSources } from "../types";
import {
Filters,
LoadedOnyxDocument,
OnyxDocument,
SourceMetadata,
} from "./interfaces";
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
export const buildFilters = (
@@ -22,3 +27,16 @@ export const buildFilters = (
export function endsWithLetterOrNumber(str: string) {
return /[a-zA-Z0-9]$/.test(str);
}
// If we have a link, open it in a new tab (including if it's a file)
// If above fails and we have a file, update the presenting document
export const openDocument = (
document: OnyxDocument,
updatePresentingDocument?: (document: OnyxDocument) => void
) => {
if (document.link) {
window.open(document.link, "_blank");
} else if (document.source_type === ValidSources.File) {
updatePresentingDocument?.(document);
}
};

View File

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

View File

@@ -8,6 +8,7 @@ export interface AuthTypeMetadata {
authType: AuthType;
autoRedirect: boolean;
requiresVerification: boolean;
anonymousUserEnabled: boolean | null;
}
export const getAuthTypeMetadataSS = async (): Promise<AuthTypeMetadata> => {
@@ -16,8 +17,11 @@ export const getAuthTypeMetadataSS = async (): Promise<AuthTypeMetadata> => {
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<AuthTypeMetadata> => {
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,
};
};