Compare commits

..

7 Commits

31 changed files with 1537 additions and 1226 deletions

View File

@@ -4,7 +4,6 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from httpx_oauth.clients.google import GoogleOAuth2
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
from ee.onyx.server.analytics.api import router as analytics_router
from ee.onyx.server.auth_check import check_ee_router_auth
from ee.onyx.server.billing.api import router as billing_router
@@ -153,12 +152,9 @@ def get_application() -> FastAPI:
# License management
include_router_with_global_prefix_prepended(application, license_router)
# Unified billing API - available when license system is enabled
# Works for both self-hosted and cloud deployments
# TODO(ENG-3533): Once frontend migrates to /admin/billing/*, this becomes the
# primary billing API and /tenants/* billing endpoints can be removed
if LICENSE_ENFORCEMENT_ENABLED:
include_router_with_global_prefix_prepended(application, billing_router)
# Unified billing API - always registered in EE.
# Each endpoint is protected by the `current_admin_user` dependency (admin auth).
include_router_with_global_prefix_prepended(application, billing_router)
if MULTI_TENANT:
# Tenant management

View File

@@ -3,7 +3,6 @@ from http import HTTPStatus
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi.responses import JSONResponse
from sqlalchemy import select
@@ -53,6 +52,8 @@ from onyx.db.permission_sync_attempt import (
from onyx.db.permission_sync_attempt import (
get_recent_doc_permission_sync_attempts_for_cc_pair,
)
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_connector import RedisConnector
from onyx.redis.redis_connector_utils import get_deletion_attempt_snapshot
from onyx.redis.redis_pool import get_redis_client
@@ -87,8 +88,9 @@ def get_cc_pair_index_attempts(
cc_pair_id, db_session, user, get_editable=False
)
if not user_has_access:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user permissions"
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"CC Pair not found for current user permissions",
)
total_count = count_index_attempts_for_cc_pair(
@@ -123,8 +125,9 @@ def get_cc_pair_permission_sync_attempts(
cc_pair_id, db_session, user, get_editable=False
)
if not user_has_access:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user permissions"
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"CC Pair not found for current user permissions",
)
# Get all permission sync attempts for this cc pair
@@ -160,8 +163,9 @@ def get_cc_pair_full_info(
cc_pair_id, db_session, user, get_editable=False
)
if not cc_pair:
raise HTTPException(
status_code=404, detail="CC Pair not found for current user permissions"
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"CC Pair not found for current user permissions",
)
editable_cc_pair = get_connector_credential_pair_from_id_for_user(
cc_pair_id, db_session, user, get_editable=True
@@ -264,9 +268,9 @@ def update_cc_pair_status(
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"Connection not found for current user's permissions",
)
redis_connector = RedisConnector(tenant_id, cc_pair_id)
@@ -339,8 +343,9 @@ def update_cc_pair_name(
get_editable=True,
)
if not cc_pair:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user's permissions"
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"CC Pair not found for current user's permissions",
)
try:
@@ -351,7 +356,7 @@ def update_cc_pair_name(
)
except IntegrityError:
db_session.rollback()
raise HTTPException(status_code=400, detail="Name must be unique")
raise OnyxError(OnyxErrorCode.CONFLICT, "Name must be unique")
@router.put("/admin/cc-pair/{cc_pair_id}/property")
@@ -368,8 +373,9 @@ def update_cc_pair_property(
get_editable=True,
)
if not cc_pair:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user's permissions"
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"CC Pair not found for current user's permissions",
)
# Can we centralize logic for updating connector properties
@@ -387,8 +393,9 @@ def update_cc_pair_property(
msg = "Pruning frequency updated successfully"
else:
raise HTTPException(
status_code=400, detail=f"Property name {update_request.name} is not valid."
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Property name {update_request.name} is not valid.",
)
return StatusResponse(success=True, message=msg, data=cc_pair_id)
@@ -407,9 +414,9 @@ def get_cc_pair_last_pruned(
get_editable=False,
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="cc_pair not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"cc_pair not found for current user's permissions",
)
return cc_pair.last_pruned
@@ -431,19 +438,16 @@ def prune_cc_pair(
get_editable=False,
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"Connection not found for current user's permissions",
)
r = get_redis_client()
redis_connector = RedisConnector(tenant_id, cc_pair_id)
if redis_connector.prune.fenced:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Pruning task already in progress.",
)
raise OnyxError(OnyxErrorCode.CONFLICT, "Pruning task already in progress.")
logger.info(
f"Pruning cc_pair: cc_pair={cc_pair_id} "
@@ -455,10 +459,7 @@ def prune_cc_pair(
client_app, cc_pair, db_session, r, tenant_id
)
if not payload_id:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Pruning task creation failed.",
)
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Pruning task creation failed.")
logger.info(f"Pruning queued: cc_pair={cc_pair.id} id={payload_id}")
@@ -588,20 +589,21 @@ def associate_credential_to_connector(
delete_connector(db_session, connector_id)
db_session.commit()
raise HTTPException(
status_code=400, detail="Connector validation error: " + str(e)
raise OnyxError(
OnyxErrorCode.CONNECTOR_VALIDATION_FAILED,
"Connector validation error: " + str(e),
)
except IntegrityError as e:
logger.error(f"IntegrityError: {e}")
delete_connector(db_session, connector_id)
db_session.commit()
raise HTTPException(status_code=400, detail="Name must be unique")
raise OnyxError(OnyxErrorCode.CONFLICT, "Name must be unique")
except Exception as e:
logger.exception(f"Unexpected error: {e}")
raise HTTPException(status_code=500, detail="Unexpected error")
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Unexpected error")
@router.delete(

View File

@@ -11,7 +11,6 @@ from fastapi import APIRouter
from fastapi import Depends
from fastapi import File
from fastapi import Form
from fastapi import HTTPException
from fastapi import Query
from fastapi import Request
from fastapi import Response
@@ -115,6 +114,8 @@ from onyx.db.models import IndexAttempt
from onyx.db.models import IndexingStatus
from onyx.db.models import User
from onyx.db.models import UserRole
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.file_processing.file_types import PLAIN_TEXT_MIME_TYPE
from onyx.file_processing.file_types import WORD_PROCESSING_MIME_TYPE
from onyx.file_store.file_store import FileStore
@@ -180,7 +181,7 @@ def check_google_app_gmail_credentials_exist(
try:
return {"client_id": get_google_app_cred(DocumentSource.GMAIL).web.client_id}
except KvKeyNotFoundError:
raise HTTPException(status_code=404, detail="Google App Credentials not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Google App Credentials not found")
@router.put("/admin/connector/gmail/app-credential")
@@ -190,7 +191,7 @@ def upsert_google_app_gmail_credentials(
try:
upsert_google_app_cred(app_credentials, DocumentSource.GMAIL)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return StatusResponse(
success=True, message="Successfully saved Google App Credentials"
@@ -206,7 +207,7 @@ def delete_google_app_gmail_credentials(
delete_google_app_cred(DocumentSource.GMAIL)
cleanup_gmail_credentials(db_session=db_session)
except KvKeyNotFoundError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return StatusResponse(
success=True, message="Successfully deleted Google App Credentials"
@@ -222,7 +223,7 @@ def check_google_app_credentials_exist(
"client_id": get_google_app_cred(DocumentSource.GOOGLE_DRIVE).web.client_id
}
except KvKeyNotFoundError:
raise HTTPException(status_code=404, detail="Google App Credentials not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Google App Credentials not found")
@router.put("/admin/connector/google-drive/app-credential")
@@ -232,7 +233,7 @@ def upsert_google_app_credentials(
try:
upsert_google_app_cred(app_credentials, DocumentSource.GOOGLE_DRIVE)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return StatusResponse(
success=True, message="Successfully saved Google App Credentials"
@@ -248,7 +249,7 @@ def delete_google_app_credentials(
delete_google_app_cred(DocumentSource.GOOGLE_DRIVE)
cleanup_google_drive_credentials(db_session=db_session)
except KvKeyNotFoundError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return StatusResponse(
success=True, message="Successfully deleted Google App Credentials"
@@ -266,9 +267,7 @@ def check_google_service_gmail_account_key_exist(
).client_email
}
except KvKeyNotFoundError:
raise HTTPException(
status_code=404, detail="Google Service Account Key not found"
)
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Google Service Account Key not found")
@router.put("/admin/connector/gmail/service-account-key")
@@ -278,7 +277,7 @@ def upsert_google_service_gmail_account_key(
try:
upsert_service_account_key(service_account_key, DocumentSource.GMAIL)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return StatusResponse(
success=True, message="Successfully saved Google Service Account Key"
@@ -294,7 +293,7 @@ def delete_google_service_gmail_account_key(
delete_service_account_key(DocumentSource.GMAIL)
cleanup_gmail_credentials(db_session=db_session)
except KvKeyNotFoundError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return StatusResponse(
success=True, message="Successfully deleted Google Service Account Key"
@@ -312,9 +311,7 @@ def check_google_service_account_key_exist(
).client_email
}
except KvKeyNotFoundError:
raise HTTPException(
status_code=404, detail="Google Service Account Key not found"
)
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Google Service Account Key not found")
@router.put("/admin/connector/google-drive/service-account-key")
@@ -324,7 +321,7 @@ def upsert_google_service_account_key(
try:
upsert_service_account_key(service_account_key, DocumentSource.GOOGLE_DRIVE)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return StatusResponse(
success=True, message="Successfully saved Google Service Account Key"
@@ -340,7 +337,7 @@ def delete_google_service_account_key(
delete_service_account_key(DocumentSource.GOOGLE_DRIVE)
cleanup_google_drive_credentials(db_session=db_session)
except KvKeyNotFoundError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return StatusResponse(
success=True, message="Successfully deleted Google Service Account Key"
@@ -363,7 +360,7 @@ def upsert_service_account_credential(
name="Service Account (uploaded)",
)
except KvKeyNotFoundError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
# first delete all existing service account credentials
delete_service_account_credentials(user, db_session, DocumentSource.GOOGLE_DRIVE)
@@ -389,7 +386,7 @@ def upsert_gmail_service_account_credential(
primary_admin_email=service_account_credential_request.google_primary_admin,
)
except KvKeyNotFoundError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
# first delete all existing service account credentials
delete_service_account_credentials(user, db_session, DocumentSource.GMAIL)
@@ -440,9 +437,9 @@ def save_zip_metadata_to_file_store(
json.loads(metadata_bytes)
except json.JSONDecodeError as e:
logger.warning(f"Unable to load {ONYX_METADATA_FILENAME}: {e}")
raise HTTPException(
status_code=400,
detail=f"Unable to load {ONYX_METADATA_FILENAME}: {e}",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Unable to load {ONYX_METADATA_FILENAME}: {e}",
)
# Save to file store
@@ -500,7 +497,7 @@ def upload_files(
if is_zip_file(file):
if seen_zip:
raise HTTPException(status_code=400, detail=SEEN_ZIP_DETAIL)
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, SEEN_ZIP_DETAIL)
seen_zip = True
with zipfile.ZipFile(file.file, "r") as zf:
zip_metadata_file_id = save_zip_metadata_to_file_store(
@@ -554,7 +551,7 @@ def upload_files(
deduped_file_names.append(file.filename)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
return FileUploadResponse(
file_paths=deduped_file_paths,
file_names=deduped_file_names,
@@ -581,9 +578,9 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
) -> ConnectorCredentialPair:
cc_pair = fetch_connector_credential_pair_for_connector(db_session, connector_id)
if cc_pair is None:
raise HTTPException(
status_code=404,
detail="No Connector-Credential Pair found for this connector",
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"No Connector-Credential Pair found for this connector",
)
has_requested_access = verify_user_has_access_to_cc_pair(
@@ -604,9 +601,9 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
):
return cc_pair
raise HTTPException(
status_code=403,
detail="Access denied. User cannot manage files for this connector.",
raise OnyxError(
OnyxErrorCode.UNAUTHORIZED,
"Access denied. User cannot manage files for this connector.",
)
@@ -627,11 +624,12 @@ def list_connector_files(
"""List all files in a file connector."""
connector = fetch_connector_by_id(connector_id, db_session)
if connector is None:
raise HTTPException(status_code=404, detail="Connector not found")
raise OnyxError(OnyxErrorCode.CONNECTOR_NOT_FOUND, "Connector not found")
if connector.source != DocumentSource.FILE:
raise HTTPException(
status_code=400, detail="This endpoint only works with file connectors"
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"This endpoint only works with file connectors",
)
_ = _fetch_and_check_file_connector_cc_pair_permissions(
@@ -700,11 +698,12 @@ def update_connector_files(
files = files or []
connector = fetch_connector_by_id(connector_id, db_session)
if connector is None:
raise HTTPException(status_code=404, detail="Connector not found")
raise OnyxError(OnyxErrorCode.CONNECTOR_NOT_FOUND, "Connector not found")
if connector.source != DocumentSource.FILE:
raise HTTPException(
status_code=400, detail="This endpoint only works with file connectors"
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"This endpoint only works with file connectors",
)
# Get the connector-credential pair for indexing/pruning triggers
@@ -720,12 +719,14 @@ def update_connector_files(
try:
file_ids_list = json.loads(file_ids_to_remove)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid file_ids_to_remove format")
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR, "Invalid file_ids_to_remove format"
)
if not isinstance(file_ids_list, list):
raise HTTPException(
status_code=400,
detail="file_ids_to_remove must be a JSON-encoded list",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"file_ids_to_remove must be a JSON-encoded list",
)
# Get current connector config
@@ -750,9 +751,9 @@ def update_connector_files(
current_zip_metadata = loaded_metadata
except Exception as e:
logger.warning(f"Failed to load existing metadata file: {e}")
raise HTTPException(
status_code=500,
detail="Failed to load existing connector metadata file",
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Failed to load existing connector metadata file",
)
# Upload new files if any
@@ -807,9 +808,9 @@ def update_connector_files(
# Validate that at least one file remains
if not final_file_locations:
raise HTTPException(
status_code=400,
detail="Cannot remove all files from connector. At least one file must remain.",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Cannot remove all files from connector. At least one file must remain.",
)
# Merge and filter metadata (remove metadata for deleted files)
@@ -852,8 +853,8 @@ def update_connector_files(
updated_connector = update_connector(connector_id, connector_base, db_session)
if updated_connector is None:
raise HTTPException(
status_code=500, detail="Failed to update connector configuration"
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR, "Failed to update connector configuration"
)
# Trigger re-indexing for new files and pruning for removed files
@@ -1541,7 +1542,7 @@ def create_connector_from_model(
return connector_response
except ValueError as e:
logger.error(f"Error creating connector: {e}")
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
@router.post("/admin/connector-with-mock-credential")
@@ -1619,11 +1620,12 @@ def create_connector_with_mock_credential(
return response
except ConnectorValidationError as e:
raise HTTPException(
status_code=400, detail="Connector validation error: " + str(e)
raise OnyxError(
OnyxErrorCode.CONNECTOR_VALIDATION_FAILED,
"Connector validation error: " + str(e),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
@router.patch("/admin/connector/{connector_id}", tags=PUBLIC_API_TAGS)
@@ -1648,12 +1650,13 @@ def update_connector_from_model(
)
connector_base = connector_data.to_connector_base()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
updated_connector = update_connector(connector_id, connector_base, db_session)
if updated_connector is None:
raise HTTPException(
status_code=404, detail=f"Connector {connector_id} does not exist"
raise OnyxError(
OnyxErrorCode.CONNECTOR_NOT_FOUND,
f"Connector {connector_id} does not exist",
)
return ConnectorSnapshot(
@@ -1690,7 +1693,7 @@ def delete_connector_by_id(
connector_id=connector_id,
)
except AssertionError:
raise HTTPException(status_code=400, detail="Connector is not deletable")
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "Connector is not deletable")
@router.post("/admin/connector/run-once", tags=PUBLIC_API_TAGS)
@@ -1711,9 +1714,9 @@ def connector_run_once(
run_info.connector_id, db_session
)
except ValueError:
raise HTTPException(
status_code=404,
detail=f"Connector by id {connector_id} does not exist.",
raise OnyxError(
OnyxErrorCode.CONNECTOR_NOT_FOUND,
f"Connector by id {connector_id} does not exist.",
)
if not specified_credential_ids:
@@ -1722,15 +1725,15 @@ def connector_run_once(
if set(specified_credential_ids).issubset(set(possible_credential_ids)):
credential_ids = specified_credential_ids
else:
raise HTTPException(
status_code=400,
detail="Not all specified credentials are associated with connector",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Not all specified credentials are associated with connector",
)
if not credential_ids:
raise HTTPException(
status_code=400,
detail="Connector has no valid credentials, cannot create index attempts.",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Connector has no valid credentials, cannot create index attempts.",
)
try:
num_triggers = trigger_indexing_for_cc_pair(
@@ -1741,7 +1744,7 @@ def connector_run_once(
db_session,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
logger.info("connector_run_once - running check_for_indexing")
@@ -1795,8 +1798,8 @@ def gmail_callback(
) -> StatusResponse:
credential_id_cookie = request.cookies.get(_GMAIL_CREDENTIAL_ID_COOKIE_NAME)
if credential_id_cookie is None or not credential_id_cookie.isdigit():
raise HTTPException(
status_code=401, detail="Request did not pass CSRF verification."
raise OnyxError(
OnyxErrorCode.CSRF_FAILURE, "Request did not pass CSRF verification."
)
credential_id = int(credential_id_cookie)
verify_csrf(credential_id, callback.state)
@@ -1809,8 +1812,8 @@ def gmail_callback(
GoogleOAuthAuthenticationMethod.UPLOADED,
)
if credentials is None:
raise HTTPException(
status_code=500, detail="Unable to fetch Gmail access tokens"
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR, "Unable to fetch Gmail access tokens"
)
return StatusResponse(success=True, message="Updated Gmail access tokens")
@@ -1825,8 +1828,8 @@ def google_drive_callback(
) -> StatusResponse:
credential_id_cookie = request.cookies.get(_GOOGLE_DRIVE_CREDENTIAL_ID_COOKIE_NAME)
if credential_id_cookie is None or not credential_id_cookie.isdigit():
raise HTTPException(
status_code=401, detail="Request did not pass CSRF verification."
raise OnyxError(
OnyxErrorCode.CSRF_FAILURE, "Request did not pass CSRF verification."
)
credential_id = int(credential_id_cookie)
verify_csrf(credential_id, callback.state)
@@ -1840,8 +1843,9 @@ def google_drive_callback(
GoogleOAuthAuthenticationMethod.UPLOADED,
)
if credentials is None:
raise HTTPException(
status_code=500, detail="Unable to fetch Google Drive access tokens"
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Unable to fetch Google Drive access tokens",
)
return StatusResponse(success=True, message="Updated Google Drive access tokens")
@@ -1881,8 +1885,9 @@ def get_connector_by_id(
) -> ConnectorSnapshot | StatusResponse[int]:
connector = fetch_connector_by_id(connector_id, db_session)
if connector is None:
raise HTTPException(
status_code=404, detail=f"Connector {connector_id} does not exist"
raise OnyxError(
OnyxErrorCode.CONNECTOR_NOT_FOUND,
f"Connector {connector_id} does not exist",
)
return ConnectorSnapshot(
@@ -1915,7 +1920,9 @@ def submit_connector_request(
connector_name = request_data.connector_name.strip()
if not connector_name:
raise HTTPException(status_code=400, detail="Connector name cannot be empty")
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR, "Connector name cannot be empty"
)
# Get user identifier for telemetry
user_email = user.email if user else None

View File

@@ -4,7 +4,6 @@ from fastapi import APIRouter
from fastapi import Depends
from fastapi import File
from fastapi import Form
from fastapi import HTTPException
from fastapi import Query
from fastapi import UploadFile
from sqlalchemy.orm import Session
@@ -28,6 +27,8 @@ from onyx.db.credentials import update_credential
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import DocumentSource
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.documents.models import CredentialBase
from onyx.server.documents.models import CredentialDataUpdateRequest
from onyx.server.documents.models import CredentialSnapshot
@@ -176,18 +177,18 @@ def create_credential_with_private_key(
try:
credential_data = json.loads(credential_json)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=400,
detail=f"Invalid JSON in credential_json: {str(e)}",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Invalid JSON in credential_json: {str(e)}",
)
private_key_processor: ProcessPrivateKeyFileProtocol | None = (
FILE_TYPE_TO_FILE_PROCESSOR.get(PrivateKeyFileTypes(type_definition_key))
)
if private_key_processor is None:
raise HTTPException(
status_code=400,
detail="Invalid type definition key for private key file",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Invalid type definition key for private key file",
)
private_key_content: str = private_key_processor(uploaded_file)
@@ -251,9 +252,9 @@ def get_credential_by_id(
get_editable=False,
)
if credential is None:
raise HTTPException(
status_code=401,
detail=f"Credential {credential_id} does not exist or does not belong to user",
raise OnyxError(
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
f"Credential {credential_id} does not exist or does not belong to user",
)
return CredentialSnapshot.from_credential_db_model(credential)
@@ -275,9 +276,9 @@ def update_credential_data(
)
if credential is None:
raise HTTPException(
status_code=401,
detail=f"Credential {credential_id} does not exist or does not belong to user",
raise OnyxError(
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
f"Credential {credential_id} does not exist or does not belong to user",
)
return CredentialSnapshot.from_credential_db_model(credential)
@@ -297,18 +298,18 @@ def update_credential_private_key(
try:
credential_data = json.loads(credential_json)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=400,
detail=f"Invalid JSON in credential_json: {str(e)}",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Invalid JSON in credential_json: {str(e)}",
)
private_key_processor: ProcessPrivateKeyFileProtocol | None = (
FILE_TYPE_TO_FILE_PROCESSOR.get(PrivateKeyFileTypes(type_definition_key))
)
if private_key_processor is None:
raise HTTPException(
status_code=400,
detail="Invalid type definition key for private key file",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Invalid type definition key for private key file",
)
private_key_content: str = private_key_processor(uploaded_file)
credential_data[field_key] = private_key_content
@@ -322,9 +323,9 @@ def update_credential_private_key(
)
if credential is None:
raise HTTPException(
status_code=401,
detail=f"Credential {credential_id} does not exist or does not belong to user",
raise OnyxError(
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
f"Credential {credential_id} does not exist or does not belong to user",
)
return CredentialSnapshot.from_credential_db_model(credential)
@@ -341,9 +342,9 @@ def update_credential_from_model(
credential_id, credential_data, user, db_session
)
if updated_credential is None:
raise HTTPException(
status_code=401,
detail=f"Credential {credential_id} does not exist or does not belong to user",
raise OnyxError(
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
f"Credential {credential_id} does not exist or does not belong to user",
)
# Get credential_json value - use masking for API responses

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from sqlalchemy.orm import Session
@@ -14,6 +13,8 @@ from onyx.db.models import User
from onyx.db.search_settings import get_current_search_settings
from onyx.document_index.factory import get_default_document_index
from onyx.document_index.interfaces import VespaChunkRequest
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.natural_language_processing.utils import get_tokenizer
from onyx.prompts.prompt_utils import build_doc_context_str
from onyx.server.documents.models import ChunkInfo
@@ -43,7 +44,7 @@ def get_document_info(
)
if not inference_chunks:
raise HTTPException(status_code=404, detail="Document not found")
raise OnyxError(OnyxErrorCode.DOCUMENT_NOT_FOUND, "Document not found")
contents = [chunk.content for chunk in inference_chunks]
@@ -95,7 +96,7 @@ def get_chunk_info(
)
if not inference_chunks:
raise HTTPException(status_code=404, detail="Chunk not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Chunk not found")
chunk_content = inference_chunks[0].content

View File

@@ -2,9 +2,10 @@ import base64
from enum import Enum
from typing import Protocol
from fastapi import HTTPException
from fastapi import UploadFile
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.documents.document_utils import validate_pkcs12_content
@@ -31,8 +32,9 @@ def process_sharepoint_private_key_file(file: UploadFile) -> str:
"""
# First check file extension (basic filter)
if not (file.filename and file.filename.lower().endswith(".pfx")):
raise HTTPException(
status_code=400, detail="Invalid file type. Only .pfx files are supported."
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Invalid file type. Only .pfx files are supported.",
)
# Read file content for validation and processing
@@ -40,9 +42,9 @@ def process_sharepoint_private_key_file(file: UploadFile) -> str:
# Validate file content to prevent extension spoofing attacks
if not validate_pkcs12_content(private_key_bytes):
raise HTTPException(
status_code=400,
detail="Invalid file content. The uploaded file does not appear to be a valid PKCS#12 (.pfx) file.",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Invalid file content. The uploaded file does not appear to be a valid PKCS#12 (.pfx) file.",
)
# Convert to base64 if validation passes

View File

@@ -5,7 +5,6 @@ from typing import cast
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi import Request
from pydantic import BaseModel
@@ -19,6 +18,8 @@ from onyx.connectors.interfaces import OAuthConnector
from onyx.db.credentials import create_credential
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_redis_client
from onyx.server.documents.models import CredentialBase
from onyx.utils.logger import setup_logger
@@ -69,12 +70,10 @@ def _get_additional_kwargs(
# validate
connector_cls.AdditionalOauthKwargs(**additional_kwargs_dict)
except ValidationError:
raise HTTPException(
status_code=400,
detail=(
f"Invalid additional kwargs. Got {additional_kwargs_dict}, expected "
f"{connector_cls.AdditionalOauthKwargs.model_json_schema()}"
),
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Invalid additional kwargs. Got {additional_kwargs_dict}, expected "
f"{connector_cls.AdditionalOauthKwargs.model_json_schema()}",
)
return additional_kwargs_dict
@@ -97,7 +96,9 @@ def oauth_authorize(
oauth_connectors = _discover_oauth_connectors()
if source not in oauth_connectors:
raise HTTPException(status_code=400, detail=f"Unknown OAuth source: {source}")
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR, f"Unknown OAuth source: {source}"
)
connector_cls = oauth_connectors[source]
base_url = WEB_DOMAIN
@@ -147,7 +148,9 @@ def oauth_callback(
oauth_connectors = _discover_oauth_connectors()
if source not in oauth_connectors:
raise HTTPException(status_code=400, detail=f"Unknown OAuth source: {source}")
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR, f"Unknown OAuth source: {source}"
)
connector_cls = oauth_connectors[source]
@@ -157,7 +160,7 @@ def oauth_callback(
bytes, redis_client.get(_OAUTH_STATE_KEY_FMT.format(state=state))
)
if not oauth_state_bytes:
raise HTTPException(status_code=400, detail="Invalid OAuth state")
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "Invalid OAuth state")
oauth_state = json.loads(oauth_state_bytes.decode("utf-8"))
desired_return_url = cast(str, oauth_state[_DESIRED_RETURN_URL_KEY])

View File

@@ -60,9 +60,11 @@ class Settings(BaseModel):
deep_research_enabled: bool | None = None
search_ui_enabled: bool | None = None
# Enterprise features flag - set by license enforcement at runtime
# When LICENSE_ENFORCEMENT_ENABLED=true, this reflects license status
# When LICENSE_ENFORCEMENT_ENABLED=false, defaults to False
# Whether EE features are unlocked for use.
# Depends on license status: True when the user has a valid license
# (ACTIVE, GRACE_PERIOD, PAYMENT_REMINDER), False when there's no license
# or the license is expired (GATED_ACCESS).
# This controls UI visibility of EE features (user groups, analytics, RBAC, etc.).
ee_features_enabled: bool = False
temperature_override_enabled: bool | None = False

View File

@@ -281,9 +281,10 @@ class TestApplyLicenseStatusToSettings:
}
class TestSettingsDefaultEEDisabled:
"""Verify the Settings model defaults ee_features_enabled to False."""
class TestSettingsDefaults:
"""Verify Settings model defaults for CE deployments."""
def test_default_ee_features_disabled(self) -> None:
"""CE default: ee_features_enabled is False."""
settings = Settings()
assert settings.ee_features_enabled is False

View File

@@ -1,8 +1,8 @@
#!/bin/bash
set -euo pipefail
set -e
# Expected resource requirements (overridden below if --lite)
# Expected resource requirements
EXPECTED_DOCKER_RAM_GB=10
EXPECTED_DISK_GB=32
@@ -10,10 +10,6 @@ EXPECTED_DISK_GB=32
SHUTDOWN_MODE=false
DELETE_DATA_MODE=false
INCLUDE_CRAFT=false # Disabled by default, use --include-craft to enable
LITE_MODE=false # Disabled by default, use --lite to enable
NO_PROMPT=false
DRY_RUN=false
VERBOSE=false
while [[ $# -gt 0 ]]; do
case $1 in
@@ -29,22 +25,6 @@ while [[ $# -gt 0 ]]; do
INCLUDE_CRAFT=true
shift
;;
--lite)
LITE_MODE=true
shift
;;
--no-prompt)
NO_PROMPT=true
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
--verbose)
VERBOSE=true
shift
;;
--help|-h)
echo "Onyx Installation Script"
echo ""
@@ -52,21 +32,15 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "Options:"
echo " --include-craft Enable Onyx Craft (AI-powered web app building)"
echo " --lite Deploy Onyx Lite (no Vespa, Redis, or model servers)"
echo " --shutdown Stop (pause) Onyx containers"
echo " --delete-data Remove all Onyx data (containers, volumes, and files)"
echo " --no-prompt Run non-interactively with defaults (for CI/automation)"
echo " --dry-run Show what would be done without making changes"
echo " --verbose Show detailed output for debugging"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Install Onyx"
echo " $0 --lite # Install Onyx Lite (minimal deployment)"
echo " $0 --include-craft # Install Onyx with Craft enabled"
echo " $0 --shutdown # Pause Onyx services"
echo " $0 --delete-data # Completely remove Onyx and all data"
echo " $0 --no-prompt # Non-interactive install with defaults"
exit 0
;;
*)
@@ -77,116 +51,8 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ "$VERBOSE" = true ]]; then
set -x
fi
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
echo "ERROR: --lite and --include-craft cannot be used together."
echo "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
# Lite mode needs far fewer resources (no Vespa, Redis, or model servers)
if [[ "$LITE_MODE" = true ]]; then
EXPECTED_DOCKER_RAM_GB=4
EXPECTED_DISK_GB=16
fi
INSTALL_ROOT="${INSTALL_PREFIX:-onyx_data}"
LITE_COMPOSE_FILE="docker-compose.onyx-lite.yml"
# Build the -f flags for docker compose. For shutdown/delete-data we auto-detect
# whether the lite overlay was previously downloaded; for install we use --lite.
compose_file_args() {
local args="-f docker-compose.yml"
if [[ "$LITE_MODE" = true ]] || [[ -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" ]]; then
args="$args -f ${LITE_COMPOSE_FILE}"
fi
echo "$args"
}
# --- Temp file cleanup ---
TMPFILES=()
cleanup_tmpfiles() {
local f
for f in "${TMPFILES[@]:-}"; do
rm -rf "$f" 2>/dev/null || true
done
}
trap cleanup_tmpfiles EXIT
mktempfile() {
local f
f="$(mktemp)"
TMPFILES+=("$f")
echo "$f"
}
# --- Downloader detection (curl with wget fallback) ---
DOWNLOADER=""
detect_downloader() {
if command -v curl &> /dev/null; then
DOWNLOADER="curl"
return 0
fi
if command -v wget &> /dev/null; then
DOWNLOADER="wget"
return 0
fi
echo "ERROR: Neither curl nor wget found. Please install one and retry."
exit 1
}
detect_downloader
download_file() {
local url="$1"
local output="$2"
if [[ "$DOWNLOADER" == "curl" ]]; then
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused -o "$output" "$url"
else
wget -q --tries=3 --timeout=20 -O "$output" "$url"
fi
}
# --- Interactive prompt helpers ---
is_interactive() {
[[ "$NO_PROMPT" = false ]] && [[ -t 0 ]]
}
prompt_or_default() {
local prompt_text="$1"
local default_value="$2"
if is_interactive; then
read -p "$prompt_text" -r REPLY
if [[ -z "$REPLY" ]]; then
REPLY="$default_value"
fi
else
REPLY="$default_value"
fi
}
prompt_yn_or_default() {
local prompt_text="$1"
local default_value="$2"
if is_interactive; then
read -p "$prompt_text" -n 1 -r
echo ""
else
REPLY="$default_value"
fi
}
prompt_enter_or_skip() {
local prompt_text="$1"
if is_interactive; then
echo -e "$prompt_text"
read -r
fi
}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -245,7 +111,7 @@ if [ "$SHUTDOWN_MODE" = true ]; then
fi
# Stop containers (without removing them)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) stop)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml stop)
if [ $? -eq 0 ]; then
print_success "Onyx containers stopped (paused)"
else
@@ -274,17 +140,12 @@ if [ "$DELETE_DATA_MODE" = true ]; then
echo " • All downloaded files and configurations"
echo " • All user data and documents"
echo ""
if is_interactive; then
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
echo ""
if [ "$REPLY" != "DELETE" ]; then
print_info "Operation cancelled."
exit 0
fi
else
print_error "Cannot confirm destructive operation in non-interactive mode."
print_info "Run interactively or remove the ${INSTALL_ROOT} directory manually."
exit 1
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
echo ""
if [ "$REPLY" != "DELETE" ]; then
print_info "Operation cancelled."
exit 0
fi
print_info "Removing Onyx containers and volumes..."
@@ -303,7 +164,7 @@ if [ "$DELETE_DATA_MODE" = true ]; then
fi
# Stop and remove containers with volumes
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) down -v)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml down -v)
if [ $? -eq 0 ]; then
print_success "Onyx containers and volumes removed"
else
@@ -337,13 +198,8 @@ echo " \____/|_| |_|\__, /_/\_\ "
echo " __/ | "
echo " |___/ "
echo -e "${NC}"
if [[ "$LITE_MODE" = true ]]; then
echo "Welcome to Onyx Lite Installation Script"
echo "========================================="
else
echo "Welcome to Onyx Installation Script"
echo "===================================="
fi
echo "Welcome to Onyx Installation Script"
echo "===================================="
echo ""
# User acknowledgment section
@@ -351,14 +207,10 @@ echo -e "${YELLOW}${BOLD}This script will:${NC}"
echo "1. Download deployment files for Onyx into a new '${INSTALL_ROOT}' directory"
echo "2. Check your system resources (Docker, memory, disk space)"
echo "3. Guide you through deployment options (version, authentication)"
if [[ "$LITE_MODE" = true ]]; then
echo ""
echo -e "${YELLOW}${BOLD}Lite mode:${NC} Vespa, Redis, and model servers will NOT be started."
echo "This gives you the core chat experience with lower resource requirements."
fi
echo ""
if is_interactive; then
# Only prompt for acknowledgment if running interactively
if [ -t 0 ]; then
echo -e "${YELLOW}${BOLD}Please acknowledge and press Enter to continue...${NC}"
read -r
echo ""
@@ -367,26 +219,6 @@ else
echo ""
fi
# Detect OS (including WSL)
IS_WSL=false
if [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then
IS_WSL=true
fi
# Dry-run: show plan and exit
if [[ "$DRY_RUN" = true ]]; then
print_info "Dry run mode — showing what would happen:"
echo " • Install root: ${INSTALL_ROOT}"
echo " • Lite mode: ${LITE_MODE}"
echo " • Include Craft: ${INCLUDE_CRAFT}"
echo " • OS type: ${OSTYPE:-unknown} (WSL: ${IS_WSL})"
echo " • Downloader: ${DOWNLOADER}"
echo " • Min RAM: ${EXPECTED_DOCKER_RAM_GB}GB, Min disk: ${EXPECTED_DISK_GB}GB"
echo ""
print_success "Dry run complete (no changes made)"
exit 0
fi
# GitHub repo base URL - using main branch
GITHUB_RAW_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deployment/docker_compose"
@@ -428,35 +260,41 @@ else
exit 1
fi
# Returns 0 if $1 <= $2, 1 if $1 > $2
# Handles missing or non-numeric parts gracefully (treats them as 0)
# Function to compare version numbers
version_compare() {
local version1="${1:-0.0.0}"
local version2="${2:-0.0.0}"
# Returns 0 if $1 <= $2, 1 if $1 > $2
local version1=$1
local version2=$2
local v1_major v1_minor v1_patch v2_major v2_minor v2_patch
v1_major=$(echo "$version1" | cut -d. -f1)
v1_minor=$(echo "$version1" | cut -d. -f2)
v1_patch=$(echo "$version1" | cut -d. -f3)
v2_major=$(echo "$version2" | cut -d. -f1)
v2_minor=$(echo "$version2" | cut -d. -f2)
v2_patch=$(echo "$version2" | cut -d. -f3)
# Split versions into components
local v1_major=$(echo $version1 | cut -d. -f1)
local v1_minor=$(echo $version1 | cut -d. -f2)
local v1_patch=$(echo $version1 | cut -d. -f3)
# Default non-numeric or empty parts to 0
[[ "$v1_major" =~ ^[0-9]+$ ]] || v1_major=0
[[ "$v1_minor" =~ ^[0-9]+$ ]] || v1_minor=0
[[ "$v1_patch" =~ ^[0-9]+$ ]] || v1_patch=0
[[ "$v2_major" =~ ^[0-9]+$ ]] || v2_major=0
[[ "$v2_minor" =~ ^[0-9]+$ ]] || v2_minor=0
[[ "$v2_patch" =~ ^[0-9]+$ ]] || v2_patch=0
local v2_major=$(echo $version2 | cut -d. -f1)
local v2_minor=$(echo $version2 | cut -d. -f2)
local v2_patch=$(echo $version2 | cut -d. -f3)
if [ "$v1_major" -lt "$v2_major" ]; then return 0
elif [ "$v1_major" -gt "$v2_major" ]; then return 1; fi
# Compare major version
if [ "$v1_major" -lt "$v2_major" ]; then
return 0
elif [ "$v1_major" -gt "$v2_major" ]; then
return 1
fi
if [ "$v1_minor" -lt "$v2_minor" ]; then return 0
elif [ "$v1_minor" -gt "$v2_minor" ]; then return 1; fi
# Compare minor version
if [ "$v1_minor" -lt "$v2_minor" ]; then
return 0
elif [ "$v1_minor" -gt "$v2_minor" ]; then
return 1
fi
[ "$v1_patch" -le "$v2_patch" ]
# Compare patch version
if [ "$v1_patch" -le "$v2_patch" ]; then
return 0
else
return 1
fi
}
# Check Docker daemon
@@ -533,7 +371,8 @@ if [ "$RESOURCE_WARNING" = true ]; then
echo ""
print_warning "Onyx recommends at least ${EXPECTED_DOCKER_RAM_GB}GB RAM and ${EXPECTED_DISK_GB}GB disk space for optimal performance."
echo ""
prompt_yn_or_default "Do you want to continue anyway? (y/N): " "y"
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Installation cancelled. Please allocate more resources and try again."
exit 1
@@ -558,9 +397,6 @@ print_info "This step downloads all necessary configuration files from GitHub...
echo ""
print_info "Downloading the following files:"
echo " • docker-compose.yml - Main Docker Compose configuration"
if [[ "$LITE_MODE" = true ]]; then
echo "${LITE_COMPOSE_FILE} - Lite mode overlay"
fi
echo " • env.template - Environment variables template"
echo " • nginx/app.conf.template - Nginx web server configuration"
echo " • nginx/run-nginx.sh - Nginx startup script"
@@ -570,7 +406,7 @@ echo ""
# Download Docker Compose file
COMPOSE_FILE="${INSTALL_ROOT}/deployment/docker-compose.yml"
print_info "Downloading docker-compose.yml..."
if download_file "${GITHUB_RAW_URL}/docker-compose.yml" "$COMPOSE_FILE" 2>/dev/null; then
if curl -fsSL -o "$COMPOSE_FILE" "${GITHUB_RAW_URL}/docker-compose.yml" 2>/dev/null; then
print_success "Docker Compose file downloaded successfully"
# Check if Docker Compose version is older than 2.24.0 and show warning
@@ -595,7 +431,8 @@ if download_file "${GITHUB_RAW_URL}/docker-compose.yml" "$COMPOSE_FILE" 2>/dev/n
echo ""
print_warning "The installation will continue, but may fail if Docker Compose cannot parse the file."
echo ""
prompt_yn_or_default "Do you want to continue anyway? (y/N): " "y"
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Installation cancelled. Please upgrade Docker Compose or manually edit the docker-compose.yml file."
exit 1
@@ -608,23 +445,10 @@ else
exit 1
fi
# Download lite overlay if --lite was requested
if [[ "$LITE_MODE" = true ]]; then
LITE_FILE="${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
print_info "Downloading ${LITE_COMPOSE_FILE} (lite overlay)..."
if download_file "${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "$LITE_FILE" 2>/dev/null; then
print_success "Lite overlay downloaded successfully"
else
print_error "Failed to download lite overlay"
print_info "Please ensure you have internet connection and try again"
exit 1
fi
fi
# Download env.template file
ENV_TEMPLATE="${INSTALL_ROOT}/deployment/env.template"
print_info "Downloading env.template..."
if download_file "${GITHUB_RAW_URL}/env.template" "$ENV_TEMPLATE" 2>/dev/null; then
if curl -fsSL -o "$ENV_TEMPLATE" "${GITHUB_RAW_URL}/env.template" 2>/dev/null; then
print_success "Environment template downloaded successfully"
else
print_error "Failed to download env.template"
@@ -638,7 +462,7 @@ NGINX_BASE_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deploym
# Download app.conf.template
NGINX_CONFIG="${INSTALL_ROOT}/data/nginx/app.conf.template"
print_info "Downloading nginx configuration template..."
if download_file "$NGINX_BASE_URL/app.conf.template" "$NGINX_CONFIG" 2>/dev/null; then
if curl -fsSL -o "$NGINX_CONFIG" "$NGINX_BASE_URL/app.conf.template" 2>/dev/null; then
print_success "Nginx configuration template downloaded"
else
print_error "Failed to download nginx configuration template"
@@ -649,7 +473,7 @@ fi
# Download run-nginx.sh script
NGINX_RUN_SCRIPT="${INSTALL_ROOT}/data/nginx/run-nginx.sh"
print_info "Downloading nginx startup script..."
if download_file "$NGINX_BASE_URL/run-nginx.sh" "$NGINX_RUN_SCRIPT" 2>/dev/null; then
if curl -fsSL -o "$NGINX_RUN_SCRIPT" "$NGINX_BASE_URL/run-nginx.sh" 2>/dev/null; then
chmod +x "$NGINX_RUN_SCRIPT"
print_success "Nginx startup script downloaded and made executable"
else
@@ -661,7 +485,7 @@ fi
# Download README file
README_FILE="${INSTALL_ROOT}/README.md"
print_info "Downloading README.md..."
if download_file "${GITHUB_RAW_URL}/README.md" "$README_FILE" 2>/dev/null; then
if curl -fsSL -o "$README_FILE" "${GITHUB_RAW_URL}/README.md" 2>/dev/null; then
print_success "README.md downloaded successfully"
else
print_error "Failed to download README.md"
@@ -689,7 +513,7 @@ if [ -d "${INSTALL_ROOT}/deployment" ] && [ -f "${INSTALL_ROOT}/deployment/docke
if [ -n "$COMPOSE_CMD" ]; then
# Check if any containers are running
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null | wc -l)
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null | wc -l)
if [ "$RUNNING_CONTAINERS" -gt 0 ]; then
print_error "Onyx services are currently running!"
echo ""
@@ -710,7 +534,7 @@ if [ -f "$ENV_FILE" ]; then
echo "• Press Enter to restart with current configuration"
echo "• Type 'update' to update to a newer version"
echo ""
prompt_or_default "Choose an option [default: restart]: " ""
read -p "Choose an option [default: restart]: " -r
echo ""
if [ "$REPLY" = "update" ]; then
@@ -719,19 +543,22 @@ if [ -f "$ENV_FILE" ]; then
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
# If --include-craft was passed, default to craft-latest
if [ "$INCLUDE_CRAFT" = true ]; then
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
read -p "Enter tag [default: craft-latest]: " -r VERSION
else
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
read -p "Enter tag [default: latest]: " -r VERSION
fi
echo ""
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest version"
if [ -z "$VERSION" ]; then
if [ "$INCLUDE_CRAFT" = true ]; then
VERSION="craft-latest"
print_info "Selected: craft-latest (Craft enabled)"
else
VERSION="latest"
print_info "Selected: Latest version"
fi
else
print_info "Selected: $VERSION"
fi
@@ -768,21 +595,23 @@ else
echo "• Press Enter for craft-latest (recommended for Craft)"
echo "• Type a specific tag (e.g., craft-v1.0.0)"
echo ""
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
read -p "Enter tag [default: craft-latest]: " -r VERSION
else
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
read -p "Enter tag [default: latest]: " -r VERSION
fi
echo ""
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest tag"
if [ -z "$VERSION" ]; then
if [ "$INCLUDE_CRAFT" = true ]; then
VERSION="craft-latest"
print_info "Selected: craft-latest (Craft enabled)"
else
VERSION="latest"
print_info "Selected: Latest tag"
fi
else
print_info "Selected: $VERSION"
fi
@@ -857,13 +686,6 @@ else
echo ""
fi
# Reject craft image tags when running in lite mode
if [[ "$LITE_MODE" = true ]] && [[ "${VERSION:-}" == craft-* ]]; then
print_error "Cannot use a craft image tag (${VERSION}) with --lite."
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
# Function to check if a port is available
is_port_available() {
local port=$1
@@ -949,7 +771,7 @@ print_step "Pulling Docker images"
print_info "This may take several minutes depending on your internet connection..."
echo ""
print_info "Downloading Docker images (this may take a while)..."
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) pull --quiet)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml pull --quiet)
if [ $? -eq 0 ]; then
print_success "Docker images downloaded successfully"
else
@@ -963,9 +785,9 @@ print_info "Launching containers..."
echo ""
if [ "$USE_LATEST" = true ]; then
print_info "Force pulling latest images and recreating containers..."
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d --pull always --force-recreate)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d --pull always --force-recreate)
else
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d)
fi
if [ $? -ne 0 ]; then
print_error "Failed to start Onyx services"
@@ -987,7 +809,7 @@ echo ""
# Check for restart loops
print_info "Checking container health status..."
RESTART_ISSUES=false
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null)
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null)
for CONTAINER in $CONTAINERS; do
PROJECT_NAME="$(basename "$INSTALL_ROOT")_deployment_"
@@ -1016,7 +838,7 @@ if [ "$RESTART_ISSUES" = true ]; then
print_error "Some containers are experiencing issues!"
echo ""
print_info "Please check the logs for more information:"
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD $(compose_file_args) logs)"
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD -f docker-compose.yml logs)"
echo ""
print_info "If the issue persists, please contact: founders@onyx.app"
@@ -1035,12 +857,8 @@ check_onyx_health() {
echo ""
while [ $attempt -le $max_attempts ]; do
local http_code=""
if [[ "$DOWNLOADER" == "curl" ]]; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port" 2>/dev/null || echo "000")
else
http_code=$(wget -q --spider -S "http://localhost:$port" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}' || echo "000")
fi
# Check for successful HTTP responses (200, 301, 302, etc.)
local http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port")
if echo "$http_code" | grep -qE "^(200|301|302|303|307|308)$"; then
return 0
fi
@@ -1096,18 +914,6 @@ print_info "If authentication is enabled, you can create your admin account here
echo " • Visit http://localhost:${HOST_PORT}/auth/signup to create your admin account"
echo " • The first user created will automatically have admin privileges"
echo ""
if [[ "$LITE_MODE" = true ]]; then
echo ""
print_info "Running in Lite mode — the following services are NOT started:"
echo " • Vespa (vector database)"
echo " • Redis (cache)"
echo " • Model servers (embedding/inference)"
echo " • Background workers (Celery)"
echo ""
print_info "Connectors and RAG search are disabled. LLM chat, tools, user file"
print_info "uploads, Projects, Agent knowledge, and code interpreter still work."
fi
echo ""
print_info "Refer to the README in the ${INSTALL_ROOT} directory for more information."
echo ""
print_info "For help or issues, contact: founders@onyx.app"

View File

@@ -1,190 +0,0 @@
"use client";
import { useState } from "react";
import Text from "@/refresh-components/texts/Text";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { QualifierContentType } from "@/refresh-components/table/types";
import { SvgCheckCircle } from "@opal/icons";
// ---------------------------------------------------------------------------
// Content type configurations
// ---------------------------------------------------------------------------
interface ContentConfig {
label: string;
content: QualifierContentType;
extraProps: Record<string, unknown>;
}
const CONTENT_TYPES: ContentConfig[] = [
{
label: "Simple",
content: "simple",
extraProps: {},
},
{
label: "Icon",
content: "icon",
extraProps: { icon: SvgCheckCircle },
},
{
label: "Image",
content: "image",
extraProps: {
imageSrc: "https://picsum.photos/36",
imageAlt: "Placeholder",
},
},
{
label: "Avatar Icon",
content: "avatar-icon",
extraProps: {},
},
{
label: "Avatar User",
content: "avatar-user",
extraProps: { initials: "AJ" },
},
];
// ---------------------------------------------------------------------------
// Row of qualifier states for a single content type
// ---------------------------------------------------------------------------
interface QualifierRowProps {
config: ContentConfig;
}
function QualifierRow({ config }: QualifierRowProps) {
const [selectableSelected, setSelectableSelected] = useState(false);
const [permanentSelected, setPermanentSelected] = useState(true);
return (
<div className="space-y-2">
<Text mainUiAction text02>
{config.label}
</Text>
<div className="flex items-start gap-8">
{/* Default */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={false}
selected={false}
disabled={false}
{...config.extraProps}
/>
<Text secondaryBody text04>
Default
</Text>
</div>
{/* Selectable (hover to reveal checkbox) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={selectableSelected}
disabled={false}
onSelectChange={setSelectableSelected}
{...config.extraProps}
/>
<Text secondaryBody text04>
Selectable
</Text>
</div>
{/* Selected */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={permanentSelected}
disabled={false}
onSelectChange={setPermanentSelected}
{...config.extraProps}
/>
<Text secondaryBody text04>
Selected
</Text>
</div>
{/* Disabled (unselected) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={false}
disabled={true}
{...config.extraProps}
/>
<Text secondaryBody text04>
Disabled
</Text>
</div>
{/* Disabled (selected) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={true}
disabled={true}
{...config.extraProps}
/>
<Text secondaryBody text04>
Disabled+Sel
</Text>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Size section — all content types at a given size
// ---------------------------------------------------------------------------
interface SizeSectionProps {
size: TableSize;
title: string;
}
function SizeSection({ size, title }: SizeSectionProps) {
return (
<div className="space-y-6">
<Text headingH3>{title}</Text>
<TableSizeProvider size={size}>
<div className="flex flex-col gap-8">
{CONTENT_TYPES.map((config) => (
<QualifierRow key={`${size}-${config.content}`} config={config} />
))}
</div>
</TableSizeProvider>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function TableQualifierDemoPage() {
return (
<div className="p-6 space-y-10">
<div className="space-y-4">
<Text headingH2>TableQualifier Demo</Text>
<Text mainContentMuted text03>
All content types, sizes, and interactive states. Hover selectable
variants to reveal the checkbox; click to toggle.
</Text>
</div>
<SizeSection size="regular" title="Regular (36px)" />
<SizeSection size="small" title="Small (28px)" />
</div>
);
}

View File

@@ -1,11 +1,12 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { FileDescriptor } from "@/app/app/interfaces";
import "katex/dist/katex.min.css";
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import useScreenSize from "@/hooks/useScreenSize";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Button } from "@opal/components";
import { SvgEdit } from "@opal/icons";
@@ -137,6 +138,7 @@ const HumanMessage = React.memo(function HumanMessage({
const [content, setContent] = useState(initialContent);
const [isEditing, setIsEditing] = useState(false);
const { isMobile } = useScreenSize();
// Use nodeId for switching (finding position in siblings)
const indexInSiblings = otherMessagesCanSwitchTo?.indexOf(nodeId);
@@ -168,119 +170,104 @@ const HumanMessage = React.memo(function HumanMessage({
return undefined;
};
const copyEditButton = useMemo(
() => (
<div className="flex flex-row flex-shrink px-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyIconButton
getCopyText={() => content}
prominence="tertiary"
data-testid="HumanMessage/copy-button"
/>
<Button
icon={SvgEdit}
prominence="tertiary"
tooltip="Edit"
onClick={() => setIsEditing(true)}
data-testid="HumanMessage/edit-button"
/>
</div>
),
[content]
);
return (
<div
id="onyx-human-message"
className="group flex flex-col justify-end w-full relative"
>
<FileDisplay alignBubble files={files || []} />
<div className="md:flex md:flex-wrap relative justify-end break-words">
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
// Don't update UI for edits that can't be persisted
if (messageId === undefined || messageId === null) {
setIsEditing(false);
return;
}
onEdit?.(editedContent, messageId);
setContent(editedContent);
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
// Don't update UI for edits that can't be persisted
if (messageId === undefined || messageId === null) {
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : typeof content === "string" ? (
<>
<div className="md:max-w-[37.5rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
<div
className={
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
onCopy={(e) => {
const selection = window.getSelection();
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
}}
>
<Text
as="p"
className="inline-block align-middle"
mainContentBody
>
{content}
</Text>
</div>
</div>
{onEdit && !isEditing && (
<div className="absolute md:relative right-0 z-content flex flex-row p-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyIconButton
getCopyText={() => content}
prominence="tertiary"
data-testid="HumanMessage/copy-button"
/>
<Button
icon={SvgEdit}
prominence="tertiary"
tooltip="Edit"
onClick={() => setIsEditing(true)}
data-testid="HumanMessage/edit-button"
/>
</div>
)}
</>
) : (
<>
return;
}
onEdit?.(editedContent, messageId);
setContent(editedContent);
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : (
<div className="flex justify-end">
{onEdit && !isMobile && copyEditButton}
<div className="md:max-w-[37.5rem]">
<div
className={cn(
"my-auto",
onEdit && !isEditing
? "opacity-0 group-hover:opacity-100 transition-opacity"
: "invisible"
)}
className={
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
onCopy={(e) => {
const selection = window.getSelection();
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
}}
>
<Button
icon={SvgEdit}
onClick={() => setIsEditing(true)}
prominence="tertiary"
tooltip="Edit"
/>
<Text
as="p"
className="inline-block align-middle"
mainContentBody
>
{content}
</Text>
</div>
<div className="ml-auto rounded-lg p-1">{content}</div>
</>
)}
<div className="md:min-w-[100%] flex justify-end order-1 mt-1">
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
</div>
)}
<div className="flex justify-end pt-1">
{!isEditing && onEdit && isMobile && copyEditButton}
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
</div>
);

View File

@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@/hooks/useToast";
export default function EEFeatureRedirect() {
const router = useRouter();
useEffect(() => {
toast.error(
"This feature requires a license. Please upgrade your plan to access."
);
router.replace("/app");
}, [router]);
return null;
}

View File

@@ -1,5 +1,6 @@
import { SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED } from "@/lib/constants";
import { fetchStandardSettingsSS } from "@/components/settings/lib";
import EEFeatureRedirect from "@/app/ee/EEFeatureRedirect";
export default async function AdminLayout({
children,
@@ -8,13 +9,7 @@ export default async function AdminLayout({
}) {
// First check build-time constant (fast path)
if (!SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">
This functionality is only available in the Enterprise Edition :(
</div>
</div>
);
return <EEFeatureRedirect />;
}
// Then check runtime license status (for license enforcement mode)
@@ -31,13 +26,7 @@ export default async function AdminLayout({
return children;
}
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">
This functionality requires an active Enterprise license.
</div>
</div>
);
return <EEFeatureRedirect />;
}
}
} catch (error) {

View File

@@ -484,12 +484,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
toggleDocumentSidebar={() => {}}
filterManager={filterManager}
llmManager={llmManager}
removeDocs={() => {}}
retrievalEnabled={retrievalEnabled}
selectedDocuments={[]}
initialMessage={message}
stopGenerating={stopGenerating}
onSubmit={handleChatInputSubmit}

View File

@@ -23,8 +23,7 @@ export interface AppModeProviderProps {
export function AppModeProvider({ children }: AppModeProviderProps) {
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const { user } = useUser();
const settings = useSettingsContext();
const { isSearchModeAvailable } = settings;
const { isSearchModeAvailable } = useSettingsContext();
const persistedMode = user?.preferences?.default_app_mode;
const [appMode, setAppModeState] = useState<AppMode>("chat");

View File

@@ -11,21 +11,8 @@ import {
* Hook to fetch billing information from Stripe.
*
* Works for both cloud and self-hosted deployments:
* - Cloud: fetches from /api/tenants/billing-information (legacy endpoint)
* - Cloud: fetches from /api/tenants/billing-information
* - Self-hosted: fetches from /api/admin/billing/billing-information
*
* Returns subscription status, seats, billing period, etc.
*
* @example
* ```tsx
* const { data, isLoading, error, refresh } = useBillingInformation();
*
* if (isLoading) return <Loading />;
* if (error) return <Error />;
* if (!data || !hasActiveSubscription(data)) return <NoSubscription />;
*
* return <BillingDetails billing={data} />;
* ```
*/
export function useBillingInformation() {
const url = NEXT_PUBLIC_CLOUD_ENABLED
@@ -38,16 +25,9 @@ export function useBillingInformation() {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30000,
// Don't auto-retry on errors (circuit breaker will block requests anyway)
shouldRetryOnError: false,
// Keep previous data while revalidating to prevent UI flashing
keepPreviousData: true,
});
return {
data,
isLoading,
error,
refresh: mutate,
};
return { data, isLoading, error, refresh: mutate };
}

View File

@@ -7,23 +7,9 @@ import { LicenseStatus } from "@/lib/billing/interfaces";
/**
* Hook to fetch license status for self-hosted deployments.
*
* Returns license information including seats, expiry, and status.
* Only fetches for self-hosted deployments (cloud uses tenant auth instead).
*
* @example
* ```tsx
* const { data, isLoading, error, refresh } = useLicense();
*
* if (isLoading) return <Loading />;
* if (error) return <Error />;
* if (!data?.has_license) return <NoLicense />;
*
* return <LicenseDetails license={data} />;
* ```
* Skips the fetch on cloud deployments (uses tenant auth instead).
*/
export function useLicense() {
// Only fetch license for self-hosted deployments
// Cloud deployments use tenant-based auth, not license files
const url = NEXT_PUBLIC_CLOUD_ENABLED ? null : "/api/license";
const { data, error, mutate, isLoading } = useSWR<LicenseStatus>(
@@ -38,20 +24,14 @@ export function useLicense() {
}
);
// Return empty state for cloud deployments
if (NEXT_PUBLIC_CLOUD_ENABLED) {
if (!url) {
return {
data: null,
data: undefined,
isLoading: false,
error: undefined,
refresh: () => Promise.resolve(undefined),
};
}
return {
data,
isLoading,
error,
refresh: mutate,
};
return { data, isLoading, error, refresh: mutate };
}

View File

@@ -46,8 +46,8 @@ export interface Settings {
// Onyx Craft (Build Mode) feature flag
onyx_craft_enabled?: boolean;
// Enterprise features flag - controlled by license enforcement at runtime
// True when user has a valid license, False for community edition
// Whether EE features are unlocked (user has a valid enterprise license).
// Controls UI visibility of EE features like user groups, analytics, RBAC.
ee_features_enabled?: boolean;
// Seat usage - populated when seat limit is exceeded

View File

@@ -190,14 +190,17 @@ function AttachmentItemLayout({
alignItems="center"
gap={1.5}
>
<Content
title={title}
description={description}
sizePreset="main-ui"
variant="section"
/>
<div className="flex-1 min-w-0">
<Content
title={title}
description={description}
sizePreset="main-ui"
variant="section"
widthVariant="full"
/>
</div>
{middleText && (
<div className="flex-1">
<div className="flex-1 min-w-0">
<Truncated text03 secondaryBody>
{middleText}
</Truncated>

View File

@@ -42,8 +42,13 @@ export const NEXT_PUBLIC_CUSTOM_REFRESH_URL =
// NOTE: this should ONLY be used on the server-side. If used client side,
// it will not be accurate (will always be false).
// Mirrors backend logic: EE is enabled if EITHER the legacy flag OR license
// enforcement is active. LICENSE_ENFORCEMENT_ENABLED defaults to true on the
// backend, so we treat undefined as enabled here to match.
export const SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED =
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() === "true";
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() ===
"true" ||
process.env.LICENSE_ENFORCEMENT_ENABLED?.toLowerCase() !== "false";
// NOTE: since this is a `NEXT_PUBLIC_` variable, it will be set at
// build-time
// TODO: consider moving this to an API call so that the api_server

View File

@@ -51,16 +51,6 @@ function ToastContainer() {
}, ANIMATION_DURATION);
}, []);
// NOTE (@raunakab):
//
// Keep this here for debugging purposes.
// useOnMount(() => {
// toast.success("Test success toast", { duration: Infinity });
// toast.error("Test error toast", { duration: Infinity });
// toast.warning("Test warning toast", { duration: Infinity });
// toast.info("Test info toast", { duration: Infinity });
// });
if (visible.length === 0) return null;
return (

View File

@@ -0,0 +1,455 @@
"use client";
"use no memo";
import { useMemo } from "react";
import { flexRender } from "@tanstack/react-table";
import useDataTable, {
toOnyxSortDirection,
} from "@/refresh-components/table/hooks/useDataTable";
import useColumnWidths from "@/refresh-components/table/hooks/useColumnWidths";
import useDraggableRows from "@/refresh-components/table/hooks/useDraggableRows";
import Table from "@/refresh-components/table/Table";
import TableHeader from "@/refresh-components/table/TableHeader";
import TableBody from "@/refresh-components/table/TableBody";
import TableRow from "@/refresh-components/table/TableRow";
import TableHead from "@/refresh-components/table/TableHead";
import TableCell from "@/refresh-components/table/TableCell";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
import Footer from "@/refresh-components/table/Footer";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
import type { ColumnDef } from "@tanstack/react-table";
import type {
DataTableProps,
DataTableFooterConfig,
OnyxColumnDef,
OnyxDataColumn,
OnyxQualifierColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
const noopGetRowId = () => "";
// ---------------------------------------------------------------------------
// Internal: resolve size-dependent widths and build TanStack columns
// ---------------------------------------------------------------------------
interface ProcessedColumns<TData> {
tanstackColumns: ColumnDef<TData, any>[];
widthConfig: WidthConfig;
qualifierColumn: OnyxQualifierColumn<TData> | null;
/** Map from column ID → OnyxColumnDef for dispatch in render loops. */
columnKindMap: Map<string, OnyxColumnDef<TData>>;
}
function processColumns<TData>(
columns: OnyxColumnDef<TData>[],
size: TableSize
): ProcessedColumns<TData> {
const tanstackColumns: ColumnDef<TData, any>[] = [];
const fixedColumnIds = new Set<string>();
const columnWeights: Record<string, number> = {};
const columnMinWidths: Record<string, number> = {};
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
for (const col of columns) {
const resolvedWidth =
typeof col.width === "function" ? col.width(size) : col.width;
// Clone def to avoid mutating the caller's column definitions
const clonedDef: ColumnDef<TData, any> = {
...col.def,
id: col.id,
size:
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight,
};
tanstackColumns.push(clonedDef);
const id = col.id;
columnKindMap.set(id, col);
if ("fixed" in resolvedWidth) {
fixedColumnIds.add(id);
} else {
columnWeights[id] = resolvedWidth.weight;
columnMinWidths[id] = resolvedWidth.minWidth ?? 50;
}
if (col.kind === "qualifier") qualifierColumn = col;
}
return {
tanstackColumns,
widthConfig: { fixedColumnIds, columnWeights, columnMinWidths },
qualifierColumn,
columnKindMap,
};
}
// ---------------------------------------------------------------------------
// DataTable component
// ---------------------------------------------------------------------------
/**
* Config-driven table component that wires together `useDataTable`,
* `useColumnWidths`, and `useDraggableRows` automatically.
*
* Full flexibility via the column definitions from `createTableColumns()`.
*
* @example
* ```tsx
* const tc = createTableColumns<TeamMember>();
* const columns = [
* tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
* tc.column("name", { header: "Name", weight: 23, minWidth: 120 }),
* tc.column("email", { header: "Email", weight: 28 }),
* tc.actions(),
* ];
*
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
* ```
*/
export default function DataTable<TData>(props: DataTableProps<TData>) {
const {
data,
columns,
pageSize,
initialSorting,
initialColumnVisibility,
draggable,
footer,
size = "regular",
onRowClick,
height,
headerBackground,
} = props;
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
// 1. Process columns (memoized on columns + size)
const { tanstackColumns, widthConfig, qualifierColumn, columnKindMap } =
useMemo(() => processColumns(columns, size), [columns, size]);
// 2. Call useDataTable
const {
table,
currentPage,
totalPages,
totalItems,
setPage,
pageSize: resolvedPageSize,
selectionState,
selectedCount,
clearSelection,
toggleAllPageRowsSelected,
isAllPageRowsSelected,
} = useDataTable({
data,
columns: tanstackColumns,
pageSize: effectivePageSize,
initialSorting,
initialColumnVisibility,
});
// 3. Call useColumnWidths
const { containerRef, columnWidths, createResizeHandler } = useColumnWidths({
headers: table.getHeaderGroups()[0]?.headers ?? [],
...widthConfig,
});
// 4. Call useDraggableRows (conditional)
const draggableReturn = useDraggableRows({
data,
getRowId: draggable?.getRowId ?? noopGetRowId,
enabled: !!draggable && table.getState().sorting.length === 0,
onReorder: draggable?.onReorder,
});
const hasDraggable = !!draggable;
const rowVariant = hasDraggable ? "table" : "list";
const isSelectable =
qualifierColumn != null && qualifierColumn.selectable !== false;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function renderContent() {
return (
<div>
<div
className="overflow-x-auto"
ref={containerRef}
style={{
...(height != null
? {
maxHeight:
typeof height === "number" ? `${height}px` : height,
overflowY: "auto" as const,
}
: undefined),
...(headerBackground
? ({
"--table-header-bg": headerBackground,
} as React.CSSProperties)
: undefined),
}}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header, headerIndex) => {
const colDef = columnKindMap.get(header.id);
// Qualifier header
if (colDef?.kind === "qualifier") {
if (qualifierColumn?.header === false) {
return (
<QualifierContainer key={header.id} type="head" />
);
}
return (
<QualifierContainer key={header.id} type="head">
<TableQualifier
content={
qualifierColumn?.headerContentType ?? "simple"
}
selectable={isSelectable}
selected={isSelectable && isAllPageRowsSelected}
onSelectChange={
isSelectable
? (checked) =>
toggleAllPageRowsSelected(checked)
: undefined
}
/>
</QualifierContainer>
);
}
// Actions header
if (colDef?.kind === "actions") {
const actionsDef = colDef as OnyxActionsColumn<TData>;
return (
<ActionsContainer key={header.id} type="head">
{actionsDef.showColumnVisibility !== false && (
<ColumnVisibilityPopover
table={table}
columnVisibility={
table.getState().columnVisibility
}
size={size}
/>
)}
{actionsDef.showSorting !== false && (
<SortingPopover
table={table}
sorting={table.getState().sorting}
size={size}
footerText={actionsDef.sortingFooterText}
/>
)}
</ActionsContainer>
);
}
// Data / Display header
const canSort = header.column.getCanSort();
const sortDir = header.column.getIsSorted();
const nextHeader = headerGroup.headers[headerIndex + 1];
const canResize =
header.column.getCanResize() &&
!!nextHeader &&
!widthConfig.fixedColumnIds.has(nextHeader.id);
const dataCol =
colDef?.kind === "data"
? (colDef as OnyxDataColumn<TData>)
: null;
return (
<TableHead
key={header.id}
width={columnWidths[header.id]}
sorted={
canSort ? toOnyxSortDirection(sortDir) : undefined
}
onSort={
canSort
? () => header.column.toggleSorting()
: undefined
}
icon={dataCol?.icon}
resizable={canResize}
onResizeStart={
canResize
? createResizeHandler(header.id, nextHeader.id)
: undefined
}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody
dndSortable={hasDraggable ? draggableReturn : undefined}
renderDragOverlay={
hasDraggable
? (activeId) => {
const row = table
.getRowModel()
.rows.find(
(r) => draggable!.getRowId(r.original) === activeId
);
if (!row) return null;
return <DragOverlayRow row={row} variant={rowVariant} />;
}
: undefined
}
>
{table.getRowModel().rows.map((row) => {
const rowId = hasDraggable
? draggable!.getRowId(row.original)
: undefined;
return (
<TableRow
key={row.id}
variant={rowVariant}
sortableId={rowId}
selected={row.getIsSelected()}
onClick={() => {
if (onRowClick) {
onRowClick(row.original);
} else if (isSelectable) {
row.toggleSelected();
}
}}
>
{row.getVisibleCells().map((cell) => {
const cellColDef = columnKindMap.get(cell.column.id);
// Qualifier cell
if (cellColDef?.kind === "qualifier") {
const qDef = cellColDef as OnyxQualifierColumn<TData>;
return (
<QualifierContainer
key={cell.id}
type="cell"
onClick={(e) => e.stopPropagation()}
>
<TableQualifier
content={qDef.content}
initials={qDef.getInitials?.(row.original)}
icon={qDef.getIcon?.(row.original)}
imageSrc={qDef.getImageSrc?.(row.original)}
selectable={isSelectable}
selected={isSelectable && row.getIsSelected()}
onSelectChange={
isSelectable
? (checked) => {
row.toggleSelected(checked);
}
: undefined
}
/>
</QualifierContainer>
);
}
// Actions cell
if (cellColDef?.kind === "actions") {
return (
<ActionsContainer key={cell.id} type="cell">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</ActionsContainer>
);
}
// Data / Display cell
return (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{footer && renderFooter(footer)}
</div>
);
}
function renderFooter(footerConfig: DataTableFooterConfig) {
if (footerConfig.mode === "selection") {
return (
<Footer
mode="selection"
multiSelect={footerConfig.multiSelect !== false}
selectionState={selectionState}
selectedCount={selectedCount}
onClear={footerConfig.onClear ?? clearSelection}
onView={footerConfig.onView}
pageSize={resolvedPageSize}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
// Summary mode
const rangeStart =
totalItems === 0
? 0
: !isFinite(resolvedPageSize)
? 1
: (currentPage - 1) * resolvedPageSize + 1;
const rangeEnd = !isFinite(resolvedPageSize)
? totalItems
: Math.min(currentPage * resolvedPageSize, totalItems);
return (
<Footer
mode="summary"
rangeStart={rangeStart}
rangeEnd={rangeEnd}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
}

View File

@@ -0,0 +1,317 @@
# DataTable
Config-driven table built on [TanStack Table](https://tanstack.com/table). Handles column sizing (weight-based proportional distribution), drag-and-drop row reordering, pagination, row selection, column visibility, and sorting out of the box.
## Quick Start
```tsx
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
interface Person {
name: string;
email: string;
role: string;
}
// Define columns at module scope (stable reference, no re-renders)
const tc = createTableColumns<Person>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 30, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 40, minWidth: 150 }),
tc.column("role", { header: "Role", weight: 30, minWidth: 80 }),
tc.actions(),
];
function PeopleTable({ data }: { data: Person[] }) {
return (
<DataTable
data={data}
columns={columns}
pageSize={10}
footer={{ mode: "selection" }}
/>
);
}
```
## Column Builder API
`createTableColumns<TData>()` returns a typed builder with four methods. Each returns an `OnyxColumnDef<TData>` that you pass to the `columns` prop.
### `tc.qualifier(config?)`
Leading column for avatars, icons, images, or checkboxes.
| Option | Type | Default | Description |
|---|---|---|---|
| `content` | `"simple" \| "icon" \| "image" \| "avatar-icon" \| "avatar-user"` | `"simple"` | Body row content type |
| `headerContentType` | same as `content` | `"simple"` | Header row content type |
| `getInitials` | `(row: TData) => string` | - | Extract initials (for `"avatar-user"`) |
| `getIcon` | `(row: TData) => IconFunctionComponent` | - | Extract icon (for `"icon"` / `"avatar-icon"`) |
| `getImageSrc` | `(row: TData) => string` | - | Extract image src (for `"image"`) |
| `selectable` | `boolean` | `true` | Show selection checkboxes |
| `header` | `boolean` | `true` | Render qualifier content in the header |
Width is fixed: 56px at `"regular"` size, 40px at `"small"`.
```ts
tc.qualifier({
content: "avatar-user",
getInitials: (row) => row.initials,
})
```
### `tc.column(accessor, config)`
Data column with sorting, resizing, and hiding. The `accessor` is a type-safe deep key into `TData`.
| Option | Type | Default | Description |
|---|---|---|---|
| `header` | `string` | **required** | Column header label |
| `cell` | `(value: TValue, row: TData) => ReactNode` | renders value as string | Custom cell renderer |
| `enableSorting` | `boolean` | `true` | Allow sorting |
| `enableResizing` | `boolean` | `true` | Allow column resize |
| `enableHiding` | `boolean` | `true` | Allow hiding via actions popover |
| `icon` | `(sorted: SortDirection) => IconFunctionComponent` | - | Override the sort indicator icon |
| `weight` | `number` | `20` | Proportional width weight |
| `minWidth` | `number` | `50` | Minimum width in pixels |
```ts
tc.column("email", {
header: "Email",
weight: 28,
minWidth: 150,
cell: (value) => <Content sizePreset="main-ui" variant="body" title={value} prominence="muted" />,
})
```
### `tc.displayColumn(config)`
Non-accessor column for custom content (e.g. computed values, action buttons per row).
| Option | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Unique column ID |
| `header` | `string` | - | Optional header label |
| `cell` | `(row: TData) => ReactNode` | **required** | Cell renderer |
| `width` | `ColumnWidth` | **required** | `{ weight, minWidth? }` or `{ fixed }` |
| `enableHiding` | `boolean` | `true` | Allow hiding |
```ts
tc.displayColumn({
id: "fullName",
header: "Full Name",
cell: (row) => `${row.firstName} ${row.lastName}`,
width: { weight: 25, minWidth: 100 },
})
```
### `tc.actions(config?)`
Fixed-width column rendered at the trailing edge. Houses column visibility and sorting popovers in the header.
| Option | Type | Default | Description |
|---|---|---|---|
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
| `showSorting` | `boolean` | `true` | Show the sorting popover |
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
```ts
tc.actions({
sortingFooterText: "Everyone will see agents in this order.",
})
```
## DataTable Props
`DataTableProps<TData>`:
| Prop | Type | Default | Description |
|---|---|---|---|
| `data` | `TData[]` | **required** | Row data |
| `columns` | `OnyxColumnDef<TData>[]` | **required** | Columns from `createTableColumns()` |
| `pageSize` | `number` | `10` (with footer) or `data.length` (without) | Rows per page. `Infinity` disables pagination |
| `initialSorting` | `SortingState` | `[]` | TanStack sorting state |
| `initialColumnVisibility` | `VisibilityState` | `{}` | Map of column ID to `false` to hide initially |
| `draggable` | `DataTableDraggableConfig<TData>` | - | Enable drag-and-drop (see below) |
| `footer` | `DataTableFooterConfig` | - | Footer mode (see below) |
| `size` | `"regular" \| "small"` | `"regular"` | Table density variant |
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
## Footer Config
The `footer` prop accepts a discriminated union on `mode`.
### Selection mode
For tables with selectable rows. Shows a selection message + count pagination.
```ts
footer={{
mode: "selection",
multiSelect: true, // default true
onView: () => { ... }, // optional "View" button
onClear: () => { ... }, // optional "Clear" button (falls back to default clearSelection)
}}
```
### Summary mode
For read-only tables. Shows "Showing X~Y of Z" + list pagination.
```ts
footer={{ mode: "summary" }}
```
## Draggable Config
Enable drag-and-drop row reordering. DnD is automatically disabled when column sorting is active.
```ts
<DataTable
data={items}
columns={columns}
draggable={{
getRowId: (row) => row.id,
onReorder: (ids, changedOrders) => {
// ids: new ordered array of all row IDs
// changedOrders: { [id]: newIndex } for rows that moved
setItems(ids.map((id) => items.find((r) => r.id === id)!));
},
}}
/>
```
| Option | Type | Description |
|---|---|---|
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
## Sizing
The `size` prop (`"regular"` or `"small"`) affects:
- Qualifier column width (56px vs 40px)
- Actions column width (88px vs 20px)
- Footer text styles and pagination size
- All child components via `TableSizeContext`
Column widths can be responsive to size using a function:
```ts
// In types.ts, width accepts:
width: ColumnWidth | ((size: TableSize) => ColumnWidth)
// Example (this is what qualifier/actions use internally):
width: (size) => size === "small" ? { fixed: 40 } : { fixed: 56 }
```
### Width system
Data columns use **weight-based proportional distribution**. A column with `weight: 40` gets twice the space of one with `weight: 20`. When the container is narrower than the sum of `minWidth` values, columns clamp to their minimums.
Fixed columns (`{ fixed: N }`) take exactly N pixels and don't participate in proportional distribution.
Resizing uses **splitter semantics**: dragging a column border grows that column and shrinks its neighbor by the same amount, keeping total width constant.
## Advanced Examples
### Scrollable table with pinned header
```tsx
<DataTable
data={allRows}
columns={columns}
height={300}
headerBackground="var(--background-tint-00)"
/>
```
### Hidden columns on load
```tsx
<DataTable
data={data}
columns={columns}
initialColumnVisibility={{ department: false, joinDate: false }}
footer={{ mode: "selection" }}
/>
```
### Icon-based data column
```tsx
const STATUS_ICONS = {
active: SvgCheckCircle,
pending: SvgClock,
inactive: SvgAlertCircle,
} as const;
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 80,
cell: (value) => (
<Content
sizePreset="main-ui"
variant="body"
icon={STATUS_ICONS[value]}
title={value.charAt(0).toUpperCase() + value.slice(1)}
/>
),
})
```
### Non-selectable qualifier with icons
```ts
tc.qualifier({
content: "icon",
getIcon: (row) => row.icon,
selectable: false,
header: false,
})
```
### Small variant in a bordered container
```tsx
<div className="border border-border-01 rounded-lg overflow-hidden">
<DataTable
data={data}
columns={columns}
size="small"
pageSize={10}
footer={{ mode: "selection" }}
/>
</div>
```
### Custom row click handler
```tsx
<DataTable
data={data}
columns={columns}
onRowClick={(row) => router.push(`/users/${row.id}`)}
/>
```
## Source Files
| File | Purpose |
|---|---|
| `DataTable.tsx` | Main component |
| `columns.ts` | `createTableColumns` builder |
| `types.ts` | All TypeScript interfaces |
| `hooks/useDataTable.ts` | TanStack table wrapper hook |
| `hooks/useColumnWidths.ts` | Weight-based width system |
| `hooks/useDraggableRows.ts` | DnD hook (`@dnd-kit`) |
| `Footer.tsx` | Selection / Summary footer modes |
| `TableSizeContext.tsx` | Size context provider |

View File

@@ -830,12 +830,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
toggleDocumentSidebar={toggleDocumentSidebar}
filterManager={filterManager}
llmManager={llmManager}
removeDocs={() => setSelectedDocuments([])}
retrievalEnabled={retrievalEnabled}
selectedDocuments={selectedDocuments}
initialMessage={
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) ||
""

View File

@@ -173,19 +173,21 @@ export function FileCard({
removeFile && doneUploading ? () => removeFile(file.id) : undefined
}
>
<div className="max-w-[12rem]">
<div className="min-w-0 max-w-[12rem]">
<Interactive.Container border heightVariant="fit">
<AttachmentItemLayout
icon={isProcessing ? SimpleLoader : SvgFileText}
title={file.name}
description={
isProcessing
? file.status === UserFileStatus.UPLOADING
? "Uploading..."
: "Processing..."
: typeLabel
}
/>
<div className="[&_.opal-content-md-body]:min-w-0 [&_.opal-content-md-title]:break-all">
<AttachmentItemLayout
icon={isProcessing ? SimpleLoader : SvgFileText}
title={file.name}
description={
isProcessing
? file.status === UserFileStatus.UPLOADING
? "Uploading..."
: "Processing..."
: typeLabel
}
/>
</div>
<Spacer horizontal rem={0.5} />
</Interactive.Container>
</div>

View File

@@ -16,16 +16,18 @@ import { FilterManager, LlmManager, useFederatedConnectors } from "@/lib/hooks";
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
import useFilter from "@/hooks/useFilter";
import useCCPairs from "@/hooks/useCCPairs";
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import { ChatState } from "@/app/app/interfaces";
import { useForcedTools } from "@/lib/hooks/useForcedTools";
import { useAppMode } from "@/providers/AppModeProvider";
import useAppFocus from "@/hooks/useAppFocus";
import { getFormattedDateRangeString } from "@/lib/dateUtils";
import { truncateString, cn, isImageFile } from "@/lib/utils";
import { cn, isImageFile } from "@/lib/utils";
import { Disabled } from "@/refresh-components/Disabled";
import { useUser } from "@/providers/UserProvider";
import { SettingsContext } from "@/providers/SettingsProvider";
import {
SettingsContext,
useVectorDbEnabled,
} from "@/providers/SettingsProvider";
import { useProjectsContext } from "@/providers/ProjectsContext";
import { FileCard } from "@/sections/cards/FileCard";
import {
@@ -40,9 +42,6 @@ import {
} from "@/app/app/services/actionUtils";
import {
SvgArrowUp,
SvgCalendar,
SvgFiles,
SvgFileText,
SvgGlobe,
SvgHourglass,
SvgPlus,
@@ -51,64 +50,22 @@ import {
SvgStop,
SvgX,
} from "@opal/icons";
import { Button, OpenButton } from "@opal/components";
import { Button } from "@opal/components";
import Popover from "@/refresh-components/Popover";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { Section } from "@/layouts/general-layouts";
import Spacer from "@/refresh-components/Spacer";
const LINE_HEIGHT = 24;
const MIN_INPUT_HEIGHT = 44;
const MAX_INPUT_HEIGHT = 200;
export interface SourceChipProps {
icon?: React.ReactNode;
title: string;
onRemove?: () => void;
onClick?: () => void;
truncateTitle?: boolean;
}
export function SourceChip({
icon,
title,
onRemove,
onClick,
truncateTitle = true,
}: SourceChipProps) {
return (
<div
onClick={onClick ? onClick : undefined}
className={cn(
"flex-none flex items-center px-1 bg-background-neutral-01 text-xs text-text-04 border border-border-01 rounded-08 box-border gap-x-1 h-6",
onClick && "cursor-pointer"
)}
>
{icon}
{truncateTitle ? truncateString(title, 20) : title}
{onRemove && (
<SvgX
size={12}
className="text-text-01 ml-auto cursor-pointer"
onClick={(e: React.MouseEvent<SVGSVGElement>) => {
e.stopPropagation();
onRemove();
}}
/>
)}
</div>
);
}
export interface AppInputBarHandle {
reset: () => void;
focus: () => void;
}
export interface AppInputBarProps {
removeDocs: () => void;
selectedDocuments: OnyxDocument[];
initialMessage?: string;
stopGenerating: () => void;
onSubmit: (message: string) => void;
@@ -120,10 +77,8 @@ export interface AppInputBarProps {
// agents
selectedAgent: MinimalPersonaSnapshot | undefined;
toggleDocumentSidebar: () => void;
handleFileUpload: (files: File[]) => void;
filterManager: FilterManager;
retrievalEnabled: boolean;
deepResearchEnabled: boolean;
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
toggleDeepResearch: () => void;
@@ -137,18 +92,13 @@ export interface AppInputBarProps {
const AppInputBar = React.memo(
({
retrievalEnabled,
removeDocs,
toggleDocumentSidebar,
filterManager,
selectedDocuments,
initialMessage = "",
stopGenerating,
onSubmit,
chatState,
currentSessionFileTokenCount,
availableContextTokens,
// agents
selectedAgent,
handleFileUpload,
@@ -165,6 +115,9 @@ const AppInputBar = React.memo(
// Internal message state - kept local to avoid parent re-renders on every keystroke
const [message, setMessage] = useState(initialMessage);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const textAreaWrapperRef = useRef<HTMLDivElement>(null);
const filesWrapperRef = useRef<HTMLDivElement>(null);
const filesContentRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { user } = useUser();
const { isClassifying, classification } = useQueryController();
@@ -178,6 +131,16 @@ const AppInputBar = React.memo(
textAreaRef.current?.focus();
},
}));
// Sync non-empty prop changes to internal state (e.g. NRFPage reads URL params
// after mount). Intentionally skips empty strings — clearing is handled via the
// imperative ref.reset() method, not by passing initialMessage="".
useEffect(() => {
if (initialMessage) {
setMessage(initialMessage);
}
}, [initialMessage]);
const { appMode } = useAppMode();
const appFocus = useAppFocus();
const isSearchMode =
@@ -227,46 +190,39 @@ const AppInputBar = React.memo(
const combinedSettings = useContext(SettingsContext);
// Track previous message to detect when lines might decrease
const prevMessageRef = useRef("");
// Auto-resize textarea based on content
// TODO(@raunakab): Replace this useEffect with CSS `field-sizing: content` once
// Firefox ships it unflagged (currently behind `layout.css.field-sizing.enabled`).
// Auto-resize textarea based on content (chat mode only).
// Reset to min-height first so scrollHeight reflects actual content size,
// then clamp between min and max. This handles both growing and shrinking.
useEffect(() => {
if (isSearchMode) return;
const wrapper = textAreaWrapperRef.current;
const textarea = textAreaRef.current;
if (textarea) {
const prevLineCount = (prevMessageRef.current.match(/\n/g) || [])
.length;
const currLineCount = (message.match(/\n/g) || []).length;
const lineRemoved = currLineCount < prevLineCount;
prevMessageRef.current = message;
if (!wrapper || !textarea) return;
if (message.length === 0) {
textarea.style.height = `${MIN_INPUT_HEIGHT}px`;
return;
} else if (lineRemoved) {
const linesRemoved = prevLineCount - currLineCount;
textarea.style.height = `${Math.max(
MIN_INPUT_HEIGHT,
Math.min(
textarea.scrollHeight - LINE_HEIGHT * linesRemoved,
MAX_INPUT_HEIGHT
)
)}px`;
} else {
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
}
}
wrapper.style.height = `${MIN_INPUT_HEIGHT}px`;
wrapper.style.height = `${Math.min(
Math.max(textarea.scrollHeight, MIN_INPUT_HEIGHT),
MAX_INPUT_HEIGHT
)}px`;
}, [message, isSearchMode]);
// Animate attached files wrapper to its content height so CSS transitions
// can interpolate between concrete pixel values (0px ↔ Npx).
const showFiles = !isSearchMode && currentMessageFiles.length > 0;
useEffect(() => {
if (initialMessage) {
setMessage(initialMessage);
const wrapper = filesWrapperRef.current;
const content = filesContentRef.current;
if (!wrapper || !content) return;
if (showFiles) {
// Measure the inner content's actual height, then add padding (p-1 = 8px total)
const PADDING = 8;
wrapper.style.height = `${content.offsetHeight + PADDING}px`;
} else {
wrapper.style.height = "0px";
}
}, [initialMessage]);
}, [showFiles, currentMessageFiles]);
function handlePaste(event: React.ClipboardEvent) {
const items = event.clipboardData?.items;
@@ -294,8 +250,7 @@ const AppInputBar = React.memo(
);
const { activePromptShortcuts } = usePromptShortcuts();
const vectorDbEnabled =
combinedSettings?.settings.vector_db_enabled !== false;
const vectorDbEnabled = useVectorDbEnabled();
const { ccPairs, isLoading: ccPairsLoading } = useCCPairs(vectorDbEnabled);
const { data: federatedConnectorsData, isLoading: federatedLoading } =
useFederatedConnectors();
@@ -412,7 +367,9 @@ const AppInputBar = React.memo(
combinedSettings?.settings?.deep_research_enabled,
]);
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
function handleKeyDownForPromptShortcuts(
e: React.KeyboardEvent<HTMLTextAreaElement>
) {
if (!user?.preferences?.shortcut_enabled || !showPrompts) return;
if (e.key === "Enter") {
@@ -447,6 +404,171 @@ const AppInputBar = React.memo(
}
}
const chatControls = (
<div
{...(isSearchMode ? { inert: true } : {})}
className={cn(
"flex justify-between items-center w-full",
isSearchMode
? "opacity-0 p-0 h-0 overflow-hidden pointer-events-none"
: "opacity-100 p-1 h-[2.75rem] pointer-events-auto",
"transition-all duration-150"
)}
>
{/* Bottom left controls */}
<div className="flex flex-row items-center">
{/* (+) button - always visible */}
<FilePickerPopover
onFileClick={handleFileClick}
onPickRecent={(file: ProjectFile) => {
// Check if file with same ID already exists
if (
!currentMessageFiles.some(
(existingFile) => existingFile.file_id === file.file_id
)
) {
setCurrentMessageFiles((prev) => [...prev, file]);
}
}}
onUnpickRecent={(file: ProjectFile) => {
setCurrentMessageFiles((prev) =>
prev.filter(
(existingFile) => existingFile.file_id !== file.file_id
)
);
}}
handleUploadChange={handleUploadChange}
trigger={(open) => (
<Button
icon={SvgPlusCircle}
tooltip="Attach Files"
transient={open}
disabled={disabled}
prominence="tertiary"
/>
)}
selectedFileIds={currentMessageFiles.map((f) => f.id)}
/>
{/* Controls that load in when data is ready */}
<div
data-testid="actions-container"
className={cn(
"flex flex-row items-center",
controlsLoading && "invisible"
)}
>
{selectedAgent && selectedAgent.tools.length > 0 && (
<ActionsPopover
selectedAgent={selectedAgent}
filterManager={filterManager}
availableSources={memoizedAvailableSources}
disabled={disabled}
/>
)}
{onToggleTabReading ? (
<Button
icon={SvgGlobe}
onClick={onToggleTabReading}
variant="select"
selected={tabReadingEnabled}
foldable={!tabReadingEnabled}
disabled={disabled}
>
{tabReadingEnabled
? currentTabUrl
? (() => {
try {
return new URL(currentTabUrl).hostname;
} catch {
return currentTabUrl;
}
})()
: "Reading tab..."
: "Read this tab"}
</Button>
) : (
showDeepResearch && (
<Button
icon={SvgHourglass}
onClick={toggleDeepResearch}
variant="select"
selected={deepResearchEnabled}
foldable={!deepResearchEnabled}
disabled={disabled}
>
Deep Research
</Button>
)
)}
{selectedAgent &&
forcedToolIds.length > 0 &&
forcedToolIds.map((toolId) => {
const tool = selectedAgent.tools.find(
(tool) => tool.id === toolId
);
if (!tool) {
return null;
}
return (
<Button
key={toolId}
icon={getIconForAction(tool)}
onClick={() => {
setForcedToolIds(
forcedToolIds.filter((id) => id !== toolId)
);
}}
variant="select"
selected
disabled={disabled}
>
{tool.display_name}
</Button>
);
})}
</div>
</div>
{/* Bottom right controls */}
<div className="flex flex-row items-center gap-1">
<div
data-testid="AppInputBar/llm-popover-trigger"
className={cn(controlsLoading && "invisible")}
>
<LLMPopover
llmManager={llmManager}
requiresImageInput={hasImageFiles}
disabled={disabled}
/>
</div>
<Button
id="onyx-chat-input-send-button"
icon={
isClassifying
? SimpleLoader
: chatState === "input"
? SvgArrowUp
: SvgStop
}
disabled={
(chatState === "input" && !message) ||
hasUploadingFiles ||
isClassifying
}
onClick={() => {
if (chatState == "streaming") {
stopGenerating();
} else if (message) {
onSubmit(message);
}
}}
/>
</div>
</div>
);
return (
<Disabled disabled={disabled} allowClick>
<div
@@ -467,8 +589,17 @@ const AppInputBar = React.memo(
)}
>
{/* Attached Files */}
{currentMessageFiles.length > 0 && (
<div className="p-2 rounded-t-16 flex flex-wrap gap-1">
<div
ref={filesWrapperRef}
{...(!showFiles ? { inert: true } : {})}
className={cn(
"transition-all duration-150",
showFiles
? "opacity-100 p-1"
: "opacity-0 p-0 overflow-hidden pointer-events-none"
)}
>
<div ref={filesContentRef} className="flex flex-wrap gap-1">
{currentMessageFiles.map((file) => (
<FileCard
key={file.id}
@@ -480,76 +611,61 @@ const AppInputBar = React.memo(
/>
))}
</div>
)}
</div>
{/* Input area */}
<div
className={cn(
"flex flex-row items-center w-full",
isSearchMode && "p-1"
)}
>
<div className="flex flex-row items-center w-full">
<Popover
open={user?.preferences?.shortcut_enabled && showPrompts}
onOpenChange={setShowPrompts}
>
<Popover.Anchor asChild>
<textarea
onPaste={handlePaste}
onKeyDownCapture={handleKeyDown}
onChange={handleInputChange}
ref={textAreaRef}
id="onyx-chat-input-textarea"
className={cn(
"w-full",
"outline-none",
"bg-transparent",
"resize-none",
"placeholder:text-text-03",
"whitespace-pre-wrap",
"break-word",
"overscroll-contain",
"px-3",
isSearchMode
? "h-[40px] py-2.5 overflow-hidden"
: [
"h-[44px]", // Fixed initial height to prevent flash - useEffect will adjust as needed
"overflow-y-auto",
"pb-2",
"pt-3",
]
)}
autoFocus
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder={
isSearchMode
? "Search connected sources"
: "How can I help you today"
}
value={message}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!showPrompts &&
!event.shiftKey &&
!(event.nativeEvent as any).isComposing
) {
event.preventDefault();
if (
message &&
!disabled &&
!isClassifying &&
!hasUploadingFiles
) {
onSubmit(message);
}
<div
ref={textAreaWrapperRef}
className="px-3 py-2 flex-1 flex h-[2.75rem]"
>
<textarea
id="onyx-chat-input-textarea"
role="textarea"
ref={textAreaRef}
onPaste={handlePaste}
onKeyDownCapture={handleKeyDownForPromptShortcuts}
onChange={handleInputChange}
className={cn(
"p-[2px] w-full h-full outline-none bg-transparent resize-none placeholder:text-text-03 whitespace-pre-wrap break-words",
"overflow-y-auto"
)}
autoFocus
rows={1}
style={{ scrollbarWidth: "thin" }}
aria-multiline={true}
placeholder={
isSearchMode
? "Search connected sources"
: "How can I help you today?"
}
}}
suppressContentEditableWarning={true}
disabled={disabled}
/>
value={message}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!showPrompts &&
!event.shiftKey &&
!(event.nativeEvent as any).isComposing
) {
event.preventDefault();
if (
message &&
!disabled &&
!isClassifying &&
!hasUploadingFiles
) {
onSubmit(message);
}
}
}}
suppressContentEditableWarning={true}
disabled={disabled}
/>
</div>
</Popover.Anchor>
<Popover.Content
@@ -616,214 +732,7 @@ const AppInputBar = React.memo(
)}
</div>
{/* Source chips */}
{(selectedDocuments.length > 0 ||
filterManager.timeRange ||
filterManager.selectedDocumentSets.length > 0) && (
<div className="flex gap-x-.5 px-2">
<div className="flex gap-x-1 px-2 overflow-visible overflow-x-scroll items-end miniscroll">
{filterManager.timeRange && (
<SourceChip
truncateTitle={false}
key="time-range"
icon={<SvgCalendar size={12} />}
title={`${getFormattedDateRangeString(
filterManager.timeRange.from,
filterManager.timeRange.to
)}`}
onRemove={() => {
filterManager.setTimeRange(null);
}}
/>
)}
{filterManager.selectedDocumentSets.length > 0 &&
filterManager.selectedDocumentSets.map((docSet, index) => (
<SourceChip
key={`doc-set-${index}`}
icon={<SvgFiles size={16} />}
title={docSet}
onRemove={() => {
filterManager.setSelectedDocumentSets(
filterManager.selectedDocumentSets.filter(
(ds) => ds !== docSet
)
);
}}
/>
))}
{selectedDocuments.length > 0 && (
<SourceChip
key="selected-documents"
onClick={() => {
toggleDocumentSidebar();
}}
icon={<SvgFileText size={16} />}
title={`${selectedDocuments.length} selected`}
onRemove={removeDocs}
/>
)}
</div>
</div>
)}
{!isSearchMode && (
<div className="flex justify-between items-center w-full p-1 min-h-[40px]">
{/* Bottom left controls */}
<div className="flex flex-row items-center">
{/* (+) button - always visible */}
<FilePickerPopover
onFileClick={handleFileClick}
onPickRecent={(file: ProjectFile) => {
// Check if file with same ID already exists
if (
!currentMessageFiles.some(
(existingFile) => existingFile.file_id === file.file_id
)
) {
setCurrentMessageFiles((prev) => [...prev, file]);
}
}}
onUnpickRecent={(file: ProjectFile) => {
setCurrentMessageFiles((prev) =>
prev.filter(
(existingFile) => existingFile.file_id !== file.file_id
)
);
}}
handleUploadChange={handleUploadChange}
trigger={(open) => (
<Button
icon={SvgPlusCircle}
tooltip="Attach Files"
transient={open}
disabled={disabled}
prominence="tertiary"
/>
)}
selectedFileIds={currentMessageFiles.map((f) => f.id)}
/>
{/* Controls that load in when data is ready */}
<div
data-testid="actions-container"
className={cn(
"flex flex-row items-center",
controlsLoading && "invisible"
)}
>
{selectedAgent && selectedAgent.tools.length > 0 && (
<ActionsPopover
selectedAgent={selectedAgent}
filterManager={filterManager}
availableSources={memoizedAvailableSources}
disabled={disabled}
/>
)}
{onToggleTabReading ? (
<Button
icon={SvgGlobe}
onClick={onToggleTabReading}
variant="select"
selected={tabReadingEnabled}
foldable={!tabReadingEnabled}
disabled={disabled}
>
{tabReadingEnabled
? currentTabUrl
? (() => {
try {
return new URL(currentTabUrl).hostname;
} catch {
return currentTabUrl;
}
})()
: "Reading tab..."
: "Read this tab"}
</Button>
) : (
showDeepResearch && (
<Button
icon={SvgHourglass}
onClick={toggleDeepResearch}
variant="select"
selected={deepResearchEnabled}
foldable={!deepResearchEnabled}
disabled={disabled}
>
Deep Research
</Button>
)
)}
{selectedAgent &&
forcedToolIds.length > 0 &&
forcedToolIds.map((toolId) => {
const tool = selectedAgent.tools.find(
(tool) => tool.id === toolId
);
if (!tool) {
return null;
}
return (
<Button
key={toolId}
icon={getIconForAction(tool)}
onClick={() => {
setForcedToolIds(
forcedToolIds.filter((id) => id !== toolId)
);
}}
variant="select"
selected
disabled={disabled}
>
{tool.display_name}
</Button>
);
})}
</div>
</div>
{/* Bottom right controls */}
<div className="flex flex-row items-center gap-1">
{/* LLM popover - loads when ready */}
<div
data-testid="AppInputBar/llm-popover-trigger"
className={cn(controlsLoading && "invisible")}
>
<LLMPopover
llmManager={llmManager}
requiresImageInput={hasImageFiles}
disabled={disabled}
/>
</div>
{/* Submit button */}
<Button
id="onyx-chat-input-send-button"
icon={
isClassifying
? SimpleLoader
: chatState === "input"
? SvgArrowUp
: SvgStop
}
disabled={
(chatState === "input" && !message) ||
hasUploadingFiles ||
isClassifying
}
onClick={() => {
if (chatState == "streaming") {
stopGenerating();
} else if (message) {
onSubmit(message);
}
}}
/>
</div>
</div>
)}
{chatControls}
</div>
</Disabled>
);

View File

@@ -116,8 +116,6 @@ function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
);
}
const EMPTY_DOCS: [] = [];
/**
* Floating ChatInputBar below the AgentViewerModal.
* On submit, navigates to the agent's chat with the message pre-filled.
@@ -137,14 +135,10 @@ function AgentChatInput({ agent, onSubmit }: AgentChatInputProps) {
chatState="input"
filterManager={filterManager}
selectedAgent={agent}
selectedDocuments={EMPTY_DOCS}
removeDocs={() => {}}
stopGenerating={() => {}}
handleFileUpload={() => {}}
toggleDocumentSidebar={() => {}}
currentSessionFileTokenCount={0}
availableContextTokens={Infinity}
retrievalEnabled={false}
deepResearchEnabled={false}
toggleDeepResearch={() => {}}
disabled={false}

View File

@@ -0,0 +1,20 @@
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
test.describe("EE Feature Redirect", () => {
test("redirects to /chat with toast when EE features are not licensed", async ({
page,
eeEnabled,
}) => {
test.skip(eeEnabled, "Redirect only happens without Enterprise license");
await page.goto("/admin/theme");
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
const toastContainer = page.getByTestId("toast-container");
await expect(toastContainer).toBeVisible({ timeout: 5_000 });
await expect(
toastContainer.getByText(/only accessible with a paid license/i)
).toBeVisible();
});
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
import { loginAs } from "@tests/e2e/utils/auth";
test.describe("Appearance Theme Settings @exclusive", () => {
@@ -12,24 +12,21 @@ test.describe("Appearance Theme Settings @exclusive", () => {
consentPrompt: "I agree to the terms",
};
test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ page, eeEnabled }) => {
test.skip(
!eeEnabled,
"Enterprise license not active — skipping theme tests"
);
// Fresh session — the eeEnabled fixture already logged in to check the
// setting, so clear cookies and re-login for a clean test state.
await page.context().clearCookies();
await loginAs(page, "admin");
// Navigate first so localStorage is accessible (API-based login
// doesn't navigate, leaving the page on about:blank).
await page.goto("/admin/theme");
await page.waitForLoadState("networkidle");
// Skip the entire test when Enterprise features are not licensed.
// The /admin/theme page is gated behind ee_features_enabled and
// renders a license-required message instead of the settings form.
const eeLocked = page.getByText(
"This functionality requires an active Enterprise license."
);
if (await eeLocked.isVisible({ timeout: 1000 }).catch(() => false)) {
test.skip(true, "Enterprise license not active — skipping theme tests");
}
await expect(
page.locator('[data-label="application-name-input"]')
).toBeVisible({ timeout: 10_000 });
// Clear localStorage to ensure consent modal shows
await page.evaluate(() => {

View File

@@ -0,0 +1,43 @@
/**
* Playwright fixture that detects EE (Enterprise Edition) license state.
*
* Usage:
* ```ts
* import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
*
* test("my EE-gated test", async ({ page, eeEnabled }) => {
* test.skip(!eeEnabled, "Requires active Enterprise license");
* // ... rest of test
* });
* ```
*
* The fixture:
* - Authenticates as admin
* - Fetches /api/settings to check ee_features_enabled
* - Provides a boolean to the test BEFORE any navigation happens
*
* This lets tests call test.skip() synchronously at the top, which is the
* correct Playwright pattern — never navigate then decide to skip.
*/
import { test as base, expect } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
export const test = base.extend<{
/** Whether EE features are enabled (valid enterprise license). */
eeEnabled: boolean;
}>({
eeEnabled: async ({ page }, use) => {
await loginAs(page, "admin");
const res = await page.request.get("/api/settings");
if (!res.ok()) {
// Fail open — if we can't determine, assume EE is not enabled
await use(false);
return;
}
const settings = await res.json();
await use(settings.ee_features_enabled === true);
},
});
export { expect };