From 06dcc28d0508203ba5cd6bd0ddd6fd28333fe35c Mon Sep 17 00:00:00 2001 From: pablonyx Date: Sat, 8 Mar 2025 17:06:20 -0800 Subject: [PATCH] Improved login experience (#4178) * functional initial auth modal * k * k * k * looking good * k * k * k * k * update * k * k * misc bunch * improvements * k * address comments * k * nit * update * k --- ...2f85f932_new_column_user_tenant_mapping.py | 51 ++++ backend/ee/onyx/server/tenants/admin_api.py | 45 +++ .../server/tenants/anonymous_users_api.py | 98 ++++++ backend/ee/onyx/server/tenants/api.py | 283 ++---------------- backend/ee/onyx/server/tenants/billing_api.py | 96 ++++++ backend/ee/onyx/server/tenants/models.py | 27 ++ .../ee/onyx/server/tenants/provisioning.py | 46 +++ .../server/tenants/team_membership_api.py | 67 +++++ .../server/tenants/tenant_management_api.py | 39 +++ .../server/tenants/user_invitations_api.py | 90 ++++++ .../ee/onyx/server/tenants/user_mapping.py | 237 ++++++++++++++- backend/onyx/auth/invited_users.py | 15 + backend/onyx/auth/users.py | 17 +- backend/onyx/configs/constants.py | 1 + backend/onyx/db/models.py | 9 +- backend/onyx/server/manage/models.py | 19 +- backend/onyx/server/manage/users.py | 40 ++- backend/onyx/server/models.py | 8 +- backend/onyx/tools/built_in_tools.py | 10 +- backend/onyx/utils/url.py | 43 +++ .../confluence/test_confluence_basic.py | 1 + .../test_confluence_permissions_basic.py | 1 + .../ActionEditor.tsx} | 27 +- .../ActionTable.tsx} | 2 +- .../edit/[toolId]/DeleteToolButton.tsx | 0 .../{tools => actions}/edit/[toolId]/page.tsx | 4 +- .../app/admin/{tools => actions}/new/page.tsx | 4 +- web/src/app/admin/{tools => actions}/page.tsx | 10 +- .../app/admin/assistants/AssistantEditor.tsx | 3 +- web/src/app/admin/assistants/PersonaTable.tsx | 5 + .../document-processing/page.tsx | 4 +- web/src/app/admin/token-rate-limits/page.tsx | 4 +- web/src/app/admin/users/page.tsx | 36 ++- web/src/app/auth/create-account/page.tsx | 4 +- web/src/app/auth/join/page.tsx | 108 +++++++ web/src/app/auth/login/EmailPasswordForm.tsx | 16 +- web/src/app/auth/login/LoginPage.tsx | 17 +- web/src/app/auth/login/SignInButton.tsx | 2 +- web/src/app/chat/ChatPage.tsx | 10 +- .../app/chat/modal/ShareChatSessionModal.tsx | 7 +- .../performance/custom-analytics/page.tsx | 2 +- .../admin/whitelabeling/WhitelabelingForm.tsx | 2 +- web/src/components/admin/ClientLayout.tsx | 4 +- .../admin/connectors/AdminSidebar.tsx | 11 - .../admin/users/PendingUsersTable.tsx | 154 ++++++++++ .../users/buttons/LeaveOrganizationButton.tsx | 12 +- web/src/components/auth/AuthErrorDisplay.tsx | 2 +- web/src/components/auth/AuthFlowContainer.tsx | 2 +- web/src/components/context/AppProvider.tsx | 6 +- web/src/components/context/ModalContext.tsx | 95 ++++++ .../components/modals/ConfirmEntityModal.tsx | 29 +- web/src/components/modals/NewTeamModal.tsx | 226 ++++++++++++++ web/src/components/modals/NewTenantModal.tsx | 227 ++++++++++++++ web/src/components/user/UserProvider.tsx | 4 +- web/src/lib/types.ts | 13 +- web/src/lib/userSS.ts | 51 ++-- web/src/lib/utilsSS.ts | 41 +++ 57 files changed, 1950 insertions(+), 437 deletions(-) create mode 100644 backend/alembic_tenants/versions/ac842f85f932_new_column_user_tenant_mapping.py create mode 100644 backend/ee/onyx/server/tenants/admin_api.py create mode 100644 backend/ee/onyx/server/tenants/anonymous_users_api.py create mode 100644 backend/ee/onyx/server/tenants/billing_api.py create mode 100644 backend/ee/onyx/server/tenants/team_membership_api.py create mode 100644 backend/ee/onyx/server/tenants/tenant_management_api.py create mode 100644 backend/ee/onyx/server/tenants/user_invitations_api.py create mode 100644 backend/onyx/utils/url.py rename web/src/app/admin/{tools/ToolEditor.tsx => actions/ActionEditor.tsx} (96%) rename web/src/app/admin/{tools/ToolsTable.tsx => actions/ActionTable.tsx} (98%) rename web/src/app/admin/{tools => actions}/edit/[toolId]/DeleteToolButton.tsx (100%) rename web/src/app/admin/{tools => actions}/edit/[toolId]/page.tsx (93%) rename web/src/app/admin/{tools => actions}/new/page.tsx (85%) rename web/src/app/admin/{tools => actions}/page.tsx (83%) create mode 100644 web/src/app/auth/join/page.tsx create mode 100644 web/src/components/admin/users/PendingUsersTable.tsx create mode 100644 web/src/components/context/ModalContext.tsx create mode 100644 web/src/components/modals/NewTeamModal.tsx create mode 100644 web/src/components/modals/NewTenantModal.tsx diff --git a/backend/alembic_tenants/versions/ac842f85f932_new_column_user_tenant_mapping.py b/backend/alembic_tenants/versions/ac842f85f932_new_column_user_tenant_mapping.py new file mode 100644 index 000000000..2a2a5bff0 --- /dev/null +++ b/backend/alembic_tenants/versions/ac842f85f932_new_column_user_tenant_mapping.py @@ -0,0 +1,51 @@ +"""new column user tenant mapping + +Revision ID: ac842f85f932 +Revises: 34e3630c7f32 +Create Date: 2025-03-03 13:30:14.802874 + +""" +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "ac842f85f932" +down_revision = "34e3630c7f32" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add active column with default value of True + op.add_column( + "user_tenant_mapping", + sa.Column( + "active", + sa.Boolean(), + nullable=False, + server_default="true", + ), + schema="public", + ) + + op.drop_constraint("uq_email", "user_tenant_mapping", schema="public") + + # Create a unique index for active=true records + # This ensures a user can only be active in one tenant at a time + op.execute( + "CREATE UNIQUE INDEX uq_user_active_email_idx ON public.user_tenant_mapping (email) WHERE active = true" + ) + + +def downgrade() -> None: + # Drop the unique index for active=true records + op.execute("DROP INDEX IF EXISTS uq_user_active_email_idx") + + op.create_unique_constraint( + "uq_email", "user_tenant_mapping", ["email"], schema="public" + ) + + # Remove the active column + op.drop_column("user_tenant_mapping", "active", schema="public") diff --git a/backend/ee/onyx/server/tenants/admin_api.py b/backend/ee/onyx/server/tenants/admin_api.py new file mode 100644 index 000000000..d1dbc9274 --- /dev/null +++ b/backend/ee/onyx/server/tenants/admin_api.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from fastapi import Response + +from ee.onyx.auth.users import current_cloud_superuser +from ee.onyx.server.tenants.models import ImpersonateRequest +from ee.onyx.server.tenants.user_mapping import get_tenant_id_for_email +from onyx.auth.users import auth_backend +from onyx.auth.users import get_redis_strategy +from onyx.auth.users import User +from onyx.db.engine import get_session_with_tenant +from onyx.db.users import get_user_by_email +from onyx.utils.logger import setup_logger + +logger = setup_logger() + +router = APIRouter(prefix="/tenants") + + +@router.post("/impersonate") +async def impersonate_user( + impersonate_request: ImpersonateRequest, + _: User = Depends(current_cloud_superuser), +) -> Response: + """Allows a cloud superuser to impersonate another user by generating an impersonation JWT token""" + tenant_id = get_tenant_id_for_email(impersonate_request.email) + + with get_session_with_tenant(tenant_id=tenant_id) as tenant_session: + user_to_impersonate = get_user_by_email( + impersonate_request.email, tenant_session + ) + if user_to_impersonate is None: + raise HTTPException(status_code=404, detail="User not found") + token = await get_redis_strategy().write_token(user_to_impersonate) + + response = await auth_backend.transport.get_login_response(token) + response.set_cookie( + key="fastapiusersauth", + value=token, + httponly=True, + secure=True, + samesite="lax", + ) + return response diff --git a/backend/ee/onyx/server/tenants/anonymous_users_api.py b/backend/ee/onyx/server/tenants/anonymous_users_api.py new file mode 100644 index 000000000..0dccc0916 --- /dev/null +++ b/backend/ee/onyx/server/tenants/anonymous_users_api.py @@ -0,0 +1,98 @@ +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from fastapi import Response +from sqlalchemy.exc import IntegrityError + +from ee.onyx.auth.users import generate_anonymous_user_jwt_token +from ee.onyx.configs.app_configs import ANONYMOUS_USER_COOKIE_NAME +from ee.onyx.server.tenants.anonymous_user_path import get_anonymous_user_path +from ee.onyx.server.tenants.anonymous_user_path import ( + get_tenant_id_for_anonymous_user_path, +) +from ee.onyx.server.tenants.anonymous_user_path import modify_anonymous_user_path +from ee.onyx.server.tenants.anonymous_user_path import validate_anonymous_user_path +from ee.onyx.server.tenants.models import AnonymousUserPath +from onyx.auth.users import anonymous_user_enabled +from onyx.auth.users import current_admin_user +from onyx.auth.users import optional_user +from onyx.auth.users import User +from onyx.configs.constants import FASTAPI_USERS_AUTH_COOKIE_NAME +from onyx.db.engine import get_session_with_shared_schema +from onyx.utils.logger import setup_logger +from shared_configs.contextvars import get_current_tenant_id + +logger = setup_logger() + +router = APIRouter(prefix="/tenants") + + +@router.get("/anonymous-user-path") +async def get_anonymous_user_path_api( + _: User | None = Depends(current_admin_user), +) -> AnonymousUserPath: + tenant_id = get_current_tenant_id() + + if tenant_id is None: + raise HTTPException(status_code=404, detail="Tenant not found") + + with get_session_with_shared_schema() as db_session: + current_path = get_anonymous_user_path(tenant_id, db_session) + + return AnonymousUserPath(anonymous_user_path=current_path) + + +@router.post("/anonymous-user-path") +async def set_anonymous_user_path_api( + anonymous_user_path: str, + _: User | None = Depends(current_admin_user), +) -> None: + tenant_id = get_current_tenant_id() + try: + validate_anonymous_user_path(anonymous_user_path) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + with get_session_with_shared_schema() as db_session: + try: + modify_anonymous_user_path(tenant_id, anonymous_user_path, db_session) + except IntegrityError: + raise HTTPException( + status_code=409, + detail="The anonymous user path is already in use. Please choose a different path.", + ) + except Exception as e: + logger.exception(f"Failed to modify anonymous user path: {str(e)}") + raise HTTPException( + status_code=500, + detail="An unexpected error occurred while modifying the anonymous user path", + ) + + +@router.post("/anonymous-user") +async def login_as_anonymous_user( + anonymous_user_path: str, + _: User | None = Depends(optional_user), +) -> Response: + with get_session_with_shared_schema() as db_session: + tenant_id = get_tenant_id_for_anonymous_user_path( + anonymous_user_path, db_session + ) + if not tenant_id: + raise HTTPException(status_code=404, detail="Tenant not found") + + if not anonymous_user_enabled(tenant_id=tenant_id): + raise HTTPException(status_code=403, detail="Anonymous user is not enabled") + + token = generate_anonymous_user_jwt_token(tenant_id) + + response = Response() + response.delete_cookie(FASTAPI_USERS_AUTH_COOKIE_NAME) + response.set_cookie( + key=ANONYMOUS_USER_COOKIE_NAME, + value=token, + httponly=True, + secure=True, + samesite="strict", + ) + return response diff --git a/backend/ee/onyx/server/tenants/api.py b/backend/ee/onyx/server/tenants/api.py index de2a4b40d..7292537a8 100644 --- a/backend/ee/onyx/server/tenants/api.py +++ b/backend/ee/onyx/server/tenants/api.py @@ -1,269 +1,24 @@ -import stripe from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Response -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session -from ee.onyx.auth.users import current_cloud_superuser -from ee.onyx.auth.users import generate_anonymous_user_jwt_token -from ee.onyx.configs.app_configs import ANONYMOUS_USER_COOKIE_NAME -from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY -from ee.onyx.server.tenants.access import control_plane_dep -from ee.onyx.server.tenants.anonymous_user_path import get_anonymous_user_path -from ee.onyx.server.tenants.anonymous_user_path import ( - get_tenant_id_for_anonymous_user_path, +from ee.onyx.server.tenants.admin_api import router as admin_router +from ee.onyx.server.tenants.anonymous_users_api import router as anonymous_users_router +from ee.onyx.server.tenants.billing_api import router as billing_router +from ee.onyx.server.tenants.team_membership_api import router as team_membership_router +from ee.onyx.server.tenants.tenant_management_api import ( + router as tenant_management_router, +) +from ee.onyx.server.tenants.user_invitations_api import ( + router as user_invitations_router, ) -from ee.onyx.server.tenants.anonymous_user_path import modify_anonymous_user_path -from ee.onyx.server.tenants.anonymous_user_path import validate_anonymous_user_path -from ee.onyx.server.tenants.billing import fetch_billing_information -from ee.onyx.server.tenants.billing import fetch_stripe_checkout_session -from ee.onyx.server.tenants.billing import fetch_tenant_stripe_information -from ee.onyx.server.tenants.models import AnonymousUserPath -from ee.onyx.server.tenants.models import BillingInformation -from ee.onyx.server.tenants.models import ImpersonateRequest -from ee.onyx.server.tenants.models import ProductGatingRequest -from ee.onyx.server.tenants.models import ProductGatingResponse -from ee.onyx.server.tenants.models import SubscriptionSessionResponse -from ee.onyx.server.tenants.models import SubscriptionStatusResponse -from ee.onyx.server.tenants.product_gating import store_product_gating -from ee.onyx.server.tenants.provisioning import delete_user_from_control_plane -from ee.onyx.server.tenants.user_mapping import get_tenant_id_for_email -from ee.onyx.server.tenants.user_mapping import remove_all_users_from_tenant -from ee.onyx.server.tenants.user_mapping import remove_users_from_tenant -from onyx.auth.users import anonymous_user_enabled -from onyx.auth.users import auth_backend -from onyx.auth.users import current_admin_user -from onyx.auth.users import get_redis_strategy -from onyx.auth.users import optional_user -from onyx.auth.users import User -from onyx.configs.app_configs import WEB_DOMAIN -from onyx.configs.constants import FASTAPI_USERS_AUTH_COOKIE_NAME -from onyx.db.auth import get_user_count -from onyx.db.engine import get_session -from onyx.db.engine import get_session_with_shared_schema -from onyx.db.engine import get_session_with_tenant -from onyx.db.users import delete_user_from_db -from onyx.db.users import get_user_by_email -from onyx.server.manage.models import UserByEmail -from onyx.utils.logger import setup_logger -from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR -from shared_configs.contextvars import get_current_tenant_id -stripe.api_key = STRIPE_SECRET_KEY -logger = setup_logger() -router = APIRouter(prefix="/tenants") +# Create a main router to include all sub-routers +# Note: We don't add a prefix here as each router already has the /tenants prefix +router = APIRouter() - -@router.get("/anonymous-user-path") -async def get_anonymous_user_path_api( - _: User | None = Depends(current_admin_user), -) -> AnonymousUserPath: - tenant_id = get_current_tenant_id() - - if tenant_id is None: - raise HTTPException(status_code=404, detail="Tenant not found") - - with get_session_with_shared_schema() as db_session: - current_path = get_anonymous_user_path(tenant_id, db_session) - - return AnonymousUserPath(anonymous_user_path=current_path) - - -@router.post("/anonymous-user-path") -async def set_anonymous_user_path_api( - anonymous_user_path: str, - _: User | None = Depends(current_admin_user), -) -> None: - tenant_id = get_current_tenant_id() - try: - validate_anonymous_user_path(anonymous_user_path) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - with get_session_with_shared_schema() as db_session: - try: - modify_anonymous_user_path(tenant_id, anonymous_user_path, db_session) - except IntegrityError: - raise HTTPException( - status_code=409, - detail="The anonymous user path is already in use. Please choose a different path.", - ) - except Exception as e: - logger.exception(f"Failed to modify anonymous user path: {str(e)}") - raise HTTPException( - status_code=500, - detail="An unexpected error occurred while modifying the anonymous user path", - ) - - -@router.post("/anonymous-user") -async def login_as_anonymous_user( - anonymous_user_path: str, - _: User | None = Depends(optional_user), -) -> Response: - with get_session_with_shared_schema() as db_session: - tenant_id = get_tenant_id_for_anonymous_user_path( - anonymous_user_path, db_session - ) - if not tenant_id: - raise HTTPException(status_code=404, detail="Tenant not found") - - if not anonymous_user_enabled(tenant_id=tenant_id): - raise HTTPException(status_code=403, detail="Anonymous user is not enabled") - - token = generate_anonymous_user_jwt_token(tenant_id) - - response = Response() - response.delete_cookie(FASTAPI_USERS_AUTH_COOKIE_NAME) - response.set_cookie( - key=ANONYMOUS_USER_COOKIE_NAME, - value=token, - httponly=True, - secure=True, - samesite="strict", - ) - return response - - -@router.post("/product-gating") -def gate_product( - product_gating_request: ProductGatingRequest, _: None = Depends(control_plane_dep) -) -> ProductGatingResponse: - """ - Gating the product means that the product is not available to the tenant. - They will be directed to the billing page. - We gate the product when their subscription has ended. - """ - try: - store_product_gating( - product_gating_request.tenant_id, product_gating_request.application_status - ) - return ProductGatingResponse(updated=True, error=None) - - except Exception as e: - logger.exception("Failed to gate product") - return ProductGatingResponse(updated=False, error=str(e)) - - -@router.get("/billing-information") -async def billing_information( - _: User = Depends(current_admin_user), -) -> BillingInformation | SubscriptionStatusResponse: - logger.info("Fetching billing information") - tenant_id = get_current_tenant_id() - return fetch_billing_information(tenant_id) - - -@router.post("/create-customer-portal-session") -async def create_customer_portal_session( - _: User = Depends(current_admin_user), -) -> dict: - tenant_id = get_current_tenant_id() - - try: - stripe_info = fetch_tenant_stripe_information(tenant_id) - stripe_customer_id = stripe_info.get("stripe_customer_id") - if not stripe_customer_id: - raise HTTPException(status_code=400, detail="Stripe customer ID not found") - logger.info(stripe_customer_id) - - portal_session = stripe.billing_portal.Session.create( - customer=stripe_customer_id, - return_url=f"{WEB_DOMAIN}/admin/billing", - ) - logger.info(portal_session) - return {"url": portal_session.url} - except Exception as e: - logger.exception("Failed to create customer portal session") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/create-subscription-session") -async def create_subscription_session( - _: User = Depends(current_admin_user), -) -> SubscriptionSessionResponse: - try: - tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() - if not tenant_id: - raise HTTPException(status_code=400, detail="Tenant ID not found") - session_id = fetch_stripe_checkout_session(tenant_id) - return SubscriptionSessionResponse(sessionId=session_id) - - except Exception as e: - logger.exception("Failed to create resubscription session") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/impersonate") -async def impersonate_user( - impersonate_request: ImpersonateRequest, - _: User = Depends(current_cloud_superuser), -) -> Response: - """Allows a cloud superuser to impersonate another user by generating an impersonation JWT token""" - tenant_id = get_tenant_id_for_email(impersonate_request.email) - - with get_session_with_tenant(tenant_id=tenant_id) as tenant_session: - user_to_impersonate = get_user_by_email( - impersonate_request.email, tenant_session - ) - if user_to_impersonate is None: - raise HTTPException(status_code=404, detail="User not found") - token = await get_redis_strategy().write_token(user_to_impersonate) - - response = await auth_backend.transport.get_login_response(token) - response.set_cookie( - key="fastapiusersauth", - value=token, - httponly=True, - secure=True, - samesite="lax", - ) - return response - - -@router.post("/leave-organization") -async def leave_organization( - user_email: UserByEmail, - current_user: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - tenant_id = get_current_tenant_id() - - if current_user is None or current_user.email != user_email.user_email: - raise HTTPException( - status_code=403, detail="You can only leave the organization as yourself" - ) - - user_to_delete = get_user_by_email(user_email.user_email, db_session) - if user_to_delete is None: - raise HTTPException(status_code=404, detail="User not found") - - num_admin_users = await get_user_count(only_admin_users=True) - - should_delete_tenant = num_admin_users == 1 - - if should_delete_tenant: - logger.info( - "Last admin user is leaving the organization. Deleting tenant from control plane." - ) - try: - await delete_user_from_control_plane(tenant_id, user_to_delete.email) - logger.debug("User deleted from control plane") - except Exception as e: - logger.exception( - f"Failed to delete user from control plane for tenant {tenant_id}: {e}" - ) - raise HTTPException( - status_code=500, - detail=f"Failed to remove user from control plane: {str(e)}", - ) - - db_session.expunge(user_to_delete) - delete_user_from_db(user_to_delete, db_session) - - if should_delete_tenant: - remove_all_users_from_tenant(tenant_id) - else: - remove_users_from_tenant([user_to_delete.email], tenant_id) +# Include all the individual routers +router.include_router(admin_router) +router.include_router(anonymous_users_router) +router.include_router(billing_router) +router.include_router(team_membership_router) +router.include_router(tenant_management_router) +router.include_router(user_invitations_router) diff --git a/backend/ee/onyx/server/tenants/billing_api.py b/backend/ee/onyx/server/tenants/billing_api.py new file mode 100644 index 000000000..18da0b95d --- /dev/null +++ b/backend/ee/onyx/server/tenants/billing_api.py @@ -0,0 +1,96 @@ +import stripe +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException + +from ee.onyx.auth.users import current_admin_user +from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY +from ee.onyx.server.tenants.access import control_plane_dep +from ee.onyx.server.tenants.billing import fetch_billing_information +from ee.onyx.server.tenants.billing import fetch_stripe_checkout_session +from ee.onyx.server.tenants.billing import fetch_tenant_stripe_information +from ee.onyx.server.tenants.models import BillingInformation +from ee.onyx.server.tenants.models import ProductGatingRequest +from ee.onyx.server.tenants.models import ProductGatingResponse +from ee.onyx.server.tenants.models import SubscriptionSessionResponse +from ee.onyx.server.tenants.models import SubscriptionStatusResponse +from ee.onyx.server.tenants.product_gating import store_product_gating +from onyx.auth.users import User +from onyx.configs.app_configs import WEB_DOMAIN +from onyx.utils.logger import setup_logger +from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR +from shared_configs.contextvars import get_current_tenant_id + +stripe.api_key = STRIPE_SECRET_KEY +logger = setup_logger() + +router = APIRouter(prefix="/tenants") + + +@router.post("/product-gating") +def gate_product( + product_gating_request: ProductGatingRequest, _: None = Depends(control_plane_dep) +) -> ProductGatingResponse: + """ + Gating the product means that the product is not available to the tenant. + They will be directed to the billing page. + We gate the product when their subscription has ended. + """ + try: + store_product_gating( + product_gating_request.tenant_id, product_gating_request.application_status + ) + return ProductGatingResponse(updated=True, error=None) + + except Exception as e: + logger.exception("Failed to gate product") + return ProductGatingResponse(updated=False, error=str(e)) + + +@router.get("/billing-information") +async def billing_information( + _: User = Depends(current_admin_user), +) -> BillingInformation | SubscriptionStatusResponse: + logger.info("Fetching billing information") + tenant_id = get_current_tenant_id() + return fetch_billing_information(tenant_id) + + +@router.post("/create-customer-portal-session") +async def create_customer_portal_session( + _: User = Depends(current_admin_user), +) -> dict: + tenant_id = get_current_tenant_id() + + try: + stripe_info = fetch_tenant_stripe_information(tenant_id) + stripe_customer_id = stripe_info.get("stripe_customer_id") + if not stripe_customer_id: + raise HTTPException(status_code=400, detail="Stripe customer ID not found") + logger.info(stripe_customer_id) + + portal_session = stripe.billing_portal.Session.create( + customer=stripe_customer_id, + return_url=f"{WEB_DOMAIN}/admin/billing", + ) + logger.info(portal_session) + return {"url": portal_session.url} + except Exception as e: + logger.exception("Failed to create customer portal session") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/create-subscription-session") +async def create_subscription_session( + _: User = Depends(current_admin_user), +) -> SubscriptionSessionResponse: + try: + tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant ID not found") + session_id = fetch_stripe_checkout_session(tenant_id) + return SubscriptionSessionResponse(sessionId=session_id) + + except Exception as e: + logger.exception("Failed to create resubscription session") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/ee/onyx/server/tenants/models.py b/backend/ee/onyx/server/tenants/models.py index 7931a06a7..fc7694682 100644 --- a/backend/ee/onyx/server/tenants/models.py +++ b/backend/ee/onyx/server/tenants/models.py @@ -67,3 +67,30 @@ class ProductGatingResponse(BaseModel): class SubscriptionSessionResponse(BaseModel): sessionId: str + + +class TenantByDomainResponse(BaseModel): + tenant_id: str + number_of_users: int + creator_email: str + + +class TenantByDomainRequest(BaseModel): + email: str + + +class RequestInviteRequest(BaseModel): + tenant_id: str + + +class RequestInviteResponse(BaseModel): + success: bool + message: str + + +class PendingUserSnapshot(BaseModel): + email: str + + +class ApproveUserRequest(BaseModel): + email: str diff --git a/backend/ee/onyx/server/tenants/provisioning.py b/backend/ee/onyx/server/tenants/provisioning.py index aaf007a27..d3e523314 100644 --- a/backend/ee/onyx/server/tenants/provisioning.py +++ b/backend/ee/onyx/server/tenants/provisioning.py @@ -4,6 +4,7 @@ import uuid import aiohttp # Async HTTP client import httpx +import requests from fastapi import HTTPException from fastapi import Request from sqlalchemy import select @@ -14,6 +15,7 @@ from ee.onyx.configs.app_configs import COHERE_DEFAULT_API_KEY from ee.onyx.configs.app_configs import HUBSPOT_TRACKING_URL from ee.onyx.configs.app_configs import OPENAI_DEFAULT_API_KEY from ee.onyx.server.tenants.access import generate_data_plane_token +from ee.onyx.server.tenants.models import TenantByDomainResponse from ee.onyx.server.tenants.models import TenantCreationPayload from ee.onyx.server.tenants.models import TenantDeletionPayload from ee.onyx.server.tenants.schema_management import create_schema_if_not_exists @@ -353,3 +355,47 @@ async def delete_user_from_control_plane(tenant_id: str, email: str) -> None: raise Exception( f"Failed to delete tenant on control plane: {error_text}" ) + + +def get_tenant_by_domain_from_control_plane( + domain: str, + tenant_id: str, +) -> TenantByDomainResponse | None: + """ + Fetches tenant information from the control plane based on the email domain. + + Args: + domain: The email domain to search for (e.g., "example.com") + + Returns: + A dictionary containing tenant information if found, None otherwise + """ + token = generate_data_plane_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + try: + response = requests.get( + f"{CONTROL_PLANE_API_BASE_URL}/tenant-by-domain", + headers=headers, + json={"domain": domain, "tenant_id": tenant_id}, + ) + + if response.status_code != 200: + logger.error(f"Control plane tenant lookup failed: {response.text}") + return None + + response_data = response.json() + if not response_data: + return None + + return TenantByDomainResponse( + tenant_id=response_data.get("tenant_id"), + number_of_users=response_data.get("number_of_users"), + creator_email=response_data.get("creator_email"), + ) + except Exception as e: + logger.error(f"Error fetching tenant by domain: {str(e)}") + return None diff --git a/backend/ee/onyx/server/tenants/team_membership_api.py b/backend/ee/onyx/server/tenants/team_membership_api.py new file mode 100644 index 000000000..bdf899aaa --- /dev/null +++ b/backend/ee/onyx/server/tenants/team_membership_api.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from ee.onyx.server.tenants.provisioning import delete_user_from_control_plane +from ee.onyx.server.tenants.user_mapping import remove_all_users_from_tenant +from ee.onyx.server.tenants.user_mapping import remove_users_from_tenant +from onyx.auth.users import current_admin_user +from onyx.auth.users import User +from onyx.db.auth import get_user_count +from onyx.db.engine import get_session +from onyx.db.users import delete_user_from_db +from onyx.db.users import get_user_by_email +from onyx.server.manage.models import UserByEmail +from onyx.utils.logger import setup_logger +from shared_configs.contextvars import get_current_tenant_id + +logger = setup_logger() + +router = APIRouter(prefix="/tenants") + + +@router.post("/leave-team") +async def leave_organization( + user_email: UserByEmail, + current_user: User | None = Depends(current_admin_user), + db_session: Session = Depends(get_session), +) -> None: + tenant_id = get_current_tenant_id() + + if current_user is None or current_user.email != user_email.user_email: + raise HTTPException( + status_code=403, detail="You can only leave the organization as yourself" + ) + + user_to_delete = get_user_by_email(user_email.user_email, db_session) + if user_to_delete is None: + raise HTTPException(status_code=404, detail="User not found") + + num_admin_users = await get_user_count(only_admin_users=True) + + should_delete_tenant = num_admin_users == 1 + + if should_delete_tenant: + logger.info( + "Last admin user is leaving the organization. Deleting tenant from control plane." + ) + try: + await delete_user_from_control_plane(tenant_id, user_to_delete.email) + logger.debug("User deleted from control plane") + except Exception as e: + logger.exception( + f"Failed to delete user from control plane for tenant {tenant_id}: {e}" + ) + raise HTTPException( + status_code=500, + detail=f"Failed to remove user from control plane: {str(e)}", + ) + + db_session.expunge(user_to_delete) + delete_user_from_db(user_to_delete, db_session) + + if should_delete_tenant: + remove_all_users_from_tenant(tenant_id) + else: + remove_users_from_tenant([user_to_delete.email], tenant_id) diff --git a/backend/ee/onyx/server/tenants/tenant_management_api.py b/backend/ee/onyx/server/tenants/tenant_management_api.py new file mode 100644 index 000000000..6367a4195 --- /dev/null +++ b/backend/ee/onyx/server/tenants/tenant_management_api.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter +from fastapi import Depends + +from ee.onyx.server.tenants.models import TenantByDomainResponse +from ee.onyx.server.tenants.provisioning import get_tenant_by_domain_from_control_plane +from onyx.auth.users import current_user +from onyx.auth.users import User +from onyx.utils.logger import setup_logger +from shared_configs.contextvars import get_current_tenant_id + +logger = setup_logger() + +router = APIRouter(prefix="/tenants") + +FORBIDDEN_COMMON_EMAIL_SUBSTRINGS = [ + "gmail", + "outlook", + "yahoo", + "hotmail", + "icloud", + "msn", + "hotmail", + "hotmail.co.uk", +] + + +@router.get("/existing-team-by-domain") +def get_existing_tenant_by_domain( + user: User | None = Depends(current_user), +) -> TenantByDomainResponse | None: + if not user: + return None + domain = user.email.split("@")[1] + if any(substring in domain for substring in FORBIDDEN_COMMON_EMAIL_SUBSTRINGS): + return None + + tenant_id = get_current_tenant_id() + + return get_tenant_by_domain_from_control_plane(domain, tenant_id) diff --git a/backend/ee/onyx/server/tenants/user_invitations_api.py b/backend/ee/onyx/server/tenants/user_invitations_api.py new file mode 100644 index 000000000..97279cfff --- /dev/null +++ b/backend/ee/onyx/server/tenants/user_invitations_api.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException + +from ee.onyx.server.tenants.models import ApproveUserRequest +from ee.onyx.server.tenants.models import PendingUserSnapshot +from ee.onyx.server.tenants.models import RequestInviteRequest +from ee.onyx.server.tenants.user_mapping import accept_user_invite +from ee.onyx.server.tenants.user_mapping import approve_user_invite +from ee.onyx.server.tenants.user_mapping import deny_user_invite +from ee.onyx.server.tenants.user_mapping import invite_self_to_tenant +from onyx.auth.invited_users import get_pending_users +from onyx.auth.users import current_admin_user +from onyx.auth.users import current_user +from onyx.auth.users import User +from onyx.utils.logger import setup_logger +from shared_configs.contextvars import get_current_tenant_id + +logger = setup_logger() + +router = APIRouter(prefix="/tenants") + + +@router.post("/users/invite/request") +async def request_invite( + invite_request: RequestInviteRequest, + user: User | None = Depends(current_admin_user), +) -> None: + if user is None: + raise HTTPException(status_code=401, detail="User not authenticated") + try: + invite_self_to_tenant(user.email, invite_request.tenant_id) + except Exception as e: + logger.exception( + f"Failed to invite self to tenant {invite_request.tenant_id}: {e}" + ) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/users/pending") +def list_pending_users( + _: User | None = Depends(current_admin_user), +) -> list[PendingUserSnapshot]: + pending_emails = get_pending_users() + return [PendingUserSnapshot(email=email) for email in pending_emails] + + +@router.post("/users/invite/approve") +async def approve_user( + approve_user_request: ApproveUserRequest, + _: User | None = Depends(current_admin_user), +) -> None: + tenant_id = get_current_tenant_id() + approve_user_invite(approve_user_request.email, tenant_id) + + +@router.post("/users/invite/accept") +async def accept_invite( + invite_request: RequestInviteRequest, + user: User | None = Depends(current_user), +) -> None: + """ + Accept an invitation to join a tenant. + """ + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + accept_user_invite(user.email, invite_request.tenant_id) + except Exception as e: + logger.exception(f"Failed to accept invite: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to accept invitation") + + +@router.post("/users/invite/deny") +async def deny_invite( + invite_request: RequestInviteRequest, + user: User | None = Depends(current_user), +) -> None: + """ + Deny an invitation to join a tenant. + """ + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + deny_user_invite(user.email, invite_request.tenant_id) + except Exception as e: + logger.exception(f"Failed to deny invite: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to deny invitation") diff --git a/backend/ee/onyx/server/tenants/user_mapping.py b/backend/ee/onyx/server/tenants/user_mapping.py index b5b0fe196..530e17acc 100644 --- a/backend/ee/onyx/server/tenants/user_mapping.py +++ b/backend/ee/onyx/server/tenants/user_mapping.py @@ -1,27 +1,56 @@ -import logging - from fastapi_users import exceptions from sqlalchemy import select -from sqlalchemy.orm import Session +from onyx.auth.invited_users import get_invited_users +from onyx.auth.invited_users import get_pending_users +from onyx.auth.invited_users import write_invited_users +from onyx.auth.invited_users import write_pending_users +from onyx.db.engine import get_session_with_shared_schema from onyx.db.engine import get_session_with_tenant -from onyx.db.engine import get_sqlalchemy_engine from onyx.db.models import UserTenantMapping +from onyx.server.manage.models import TenantSnapshot +from onyx.setup import setup_logger from shared_configs.configs import MULTI_TENANT from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA +from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR -logger = logging.getLogger(__name__) +logger = setup_logger() def get_tenant_id_for_email(email: str) -> str: if not MULTI_TENANT: return POSTGRES_DEFAULT_SCHEMA # Implement logic to get tenant_id from the mapping table - with Session(get_sqlalchemy_engine()) as db_session: - result = db_session.execute( - select(UserTenantMapping.tenant_id).where(UserTenantMapping.email == email) - ) - tenant_id = result.scalar_one_or_none() + try: + with get_session_with_shared_schema() as db_session: + # First try to get an active tenant + result = db_session.execute( + select(UserTenantMapping).where( + UserTenantMapping.email == email, + UserTenantMapping.active == True, # noqa: E712 + ) + ) + mapping = result.scalar_one_or_none() + tenant_id = mapping.tenant_id if mapping else None + + # If no active tenant found, try to get the first inactive one + if tenant_id is None: + result = db_session.execute( + select(UserTenantMapping).where( + UserTenantMapping.email == email, + UserTenantMapping.active == False, # noqa: E712 + ) + ) + mapping = result.scalar_one_or_none() + if mapping: + # Mark this mapping as active + mapping.active = True + db_session.commit() + tenant_id = mapping.tenant_id + + except Exception as e: + logger.exception(f"Error getting tenant id for email {email}: {e}") + raise exceptions.UserNotExists() if tenant_id is None: raise exceptions.UserNotExists() return tenant_id @@ -41,7 +70,9 @@ def add_users_to_tenant(emails: list[str], tenant_id: str) -> None: with get_session_with_tenant(tenant_id=POSTGRES_DEFAULT_SCHEMA) as db_session: try: for email in emails: - db_session.add(UserTenantMapping(email=email, tenant_id=tenant_id)) + db_session.add( + UserTenantMapping(email=email, tenant_id=tenant_id, active=False) + ) except Exception: logger.exception(f"Failed to add users to tenant {tenant_id}") db_session.commit() @@ -76,3 +107,187 @@ def remove_all_users_from_tenant(tenant_id: str) -> None: UserTenantMapping.tenant_id == tenant_id ).delete() db_session.commit() + + +def invite_self_to_tenant(email: str, tenant_id: str) -> None: + token = CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id) + try: + pending_users = get_pending_users() + if email in pending_users: + return + write_pending_users(pending_users + [email]) + finally: + CURRENT_TENANT_ID_CONTEXTVAR.reset(token) + + +def approve_user_invite(email: str, tenant_id: str) -> None: + """ + Approve a user invite to a tenant. + This will delete all existing records for this email and create a new mapping entry for the user in this tenant. + """ + with get_session_with_shared_schema() as db_session: + # Delete all existing records for this email + db_session.query(UserTenantMapping).filter( + UserTenantMapping.email == email + ).delete() + + # Create a new mapping entry for the user in this tenant + new_mapping = UserTenantMapping(email=email, tenant_id=tenant_id, active=True) + db_session.add(new_mapping) + db_session.commit() + + # Also remove the user from pending users list + # Remove from pending users + pending_users = get_pending_users() + if email in pending_users: + pending_users.remove(email) + write_pending_users(pending_users) + + # Add to invited users + invited_users = get_invited_users() + if email not in invited_users: + invited_users.append(email) + write_invited_users(invited_users) + + +def accept_user_invite(email: str, tenant_id: str) -> None: + """ + Accept an invitation to join a tenant. + This activates the user's mapping to the tenant. + """ + with get_session_with_shared_schema() as db_session: + try: + # First check if there's an active mapping for this user and tenant + active_mapping = ( + db_session.query(UserTenantMapping) + .filter( + UserTenantMapping.email == email, + UserTenantMapping.active == True, # noqa: E712 + ) + .first() + ) + + # If an active mapping exists, delete it + if active_mapping: + db_session.delete(active_mapping) + logger.info( + f"Deleted existing active mapping for user {email} in tenant {tenant_id}" + ) + + # Find the inactive mapping for this user and tenant + mapping = ( + db_session.query(UserTenantMapping) + .filter( + UserTenantMapping.email == email, + UserTenantMapping.tenant_id == tenant_id, + UserTenantMapping.active == False, # noqa: E712 + ) + .first() + ) + + if mapping: + # Set all other mappings for this user to inactive + db_session.query(UserTenantMapping).filter( + UserTenantMapping.email == email, + UserTenantMapping.active == True, # noqa: E712 + ).update({"active": False}) + + # Activate this mapping + mapping.active = True + db_session.commit() + logger.info(f"User {email} accepted invitation to tenant {tenant_id}") + else: + logger.warning( + f"No invitation found for user {email} in tenant {tenant_id}" + ) + + except Exception as e: + db_session.rollback() + logger.exception( + f"Failed to accept invitation for user {email} to tenant {tenant_id}: {str(e)}" + ) + raise + + +def deny_user_invite(email: str, tenant_id: str) -> None: + """ + Deny an invitation to join a tenant. + This removes the user's mapping to the tenant. + """ + with get_session_with_shared_schema() as db_session: + # Delete the mapping for this user and tenant + result = ( + db_session.query(UserTenantMapping) + .filter( + UserTenantMapping.email == email, + UserTenantMapping.tenant_id == tenant_id, + UserTenantMapping.active == False, # noqa: E712 + ) + .delete() + ) + + db_session.commit() + if result: + logger.info(f"User {email} denied invitation to tenant {tenant_id}") + else: + logger.warning( + f"No invitation found for user {email} in tenant {tenant_id}" + ) + token = CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id) + try: + pending_users = get_invited_users() + if email in pending_users: + pending_users.remove(email) + write_invited_users(pending_users) + finally: + CURRENT_TENANT_ID_CONTEXTVAR.reset(token) + + +def get_tenant_count(tenant_id: str) -> int: + """ + Get the number of active users for this tenant + """ + with get_session_with_shared_schema() as db_session: + # Count the number of active users for this tenant + user_count = ( + db_session.query(UserTenantMapping) + .filter( + UserTenantMapping.tenant_id == tenant_id, + UserTenantMapping.active == True, # noqa: E712 + ) + .count() + ) + + return user_count + + +def get_tenant_invitation(email: str) -> TenantSnapshot | None: + """ + Get the first tenant invitation for this user + """ + with get_session_with_shared_schema() as db_session: + # Get the first tenant invitation for this user + invitation = ( + db_session.query(UserTenantMapping) + .filter( + UserTenantMapping.email == email, + UserTenantMapping.active == False, # noqa: E712 + ) + .first() + ) + + if invitation: + # Get the user count for this tenant + user_count = ( + db_session.query(UserTenantMapping) + .filter( + UserTenantMapping.tenant_id == invitation.tenant_id, + UserTenantMapping.active == True, # noqa: E712 + ) + .count() + ) + return TenantSnapshot( + tenant_id=invitation.tenant_id, number_of_users=user_count + ) + + return None diff --git a/backend/onyx/auth/invited_users.py b/backend/onyx/auth/invited_users.py index 896b39949..94de14cc6 100644 --- a/backend/onyx/auth/invited_users.py +++ b/backend/onyx/auth/invited_users.py @@ -1,5 +1,6 @@ from typing import cast +from onyx.configs.constants import KV_PENDING_USERS_KEY from onyx.configs.constants import KV_USER_STORE_KEY from onyx.key_value_store.factory import get_kv_store from onyx.key_value_store.interface import KvKeyNotFoundError @@ -18,3 +19,17 @@ def write_invited_users(emails: list[str]) -> int: store = get_kv_store() store.store(KV_USER_STORE_KEY, cast(JSON_ro, emails)) return len(emails) + + +def get_pending_users() -> list[str]: + try: + store = get_kv_store() + return cast(list, store.load(KV_PENDING_USERS_KEY)) + except KvKeyNotFoundError: + return list() + + +def write_pending_users(emails: list[str]) -> int: + store = get_kv_store() + store.store(KV_PENDING_USERS_KEY, cast(JSON_ro, emails)) + return len(emails) diff --git a/backend/onyx/auth/users.py b/backend/onyx/auth/users.py index d8a995c7e..4644521b1 100644 --- a/backend/onyx/auth/users.py +++ b/backend/onyx/auth/users.py @@ -100,6 +100,7 @@ from onyx.utils.logger import setup_logger from onyx.utils.telemetry import create_milestone_and_report from onyx.utils.telemetry import optional_telemetry from onyx.utils.telemetry import RecordType +from onyx.utils.url import add_url_params from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop from onyx.utils.variable_functionality import fetch_versioned_implementation from shared_configs.configs import async_return_default_schema @@ -1095,6 +1096,12 @@ def get_oauth_router( next_url = state_data.get("next_url", "/") referral_source = state_data.get("referral_source", None) + try: + tenant_id = fetch_ee_implementation_or_noop( + "onyx.server.tenants.user_mapping", "get_tenant_id_for_email", None + )(account_email) + except exceptions.UserNotExists: + tenant_id = None request.state.referral_source = referral_source @@ -1126,9 +1133,14 @@ def get_oauth_router( # Login user response = await backend.login(strategy, user) await user_manager.on_after_login(user, request, response) - # Prepare redirect response - redirect_response = RedirectResponse(next_url, status_code=302) + if tenant_id is None: + # Use URL utility to add parameters + redirect_url = add_url_params(next_url, {"new_team": "true"}) + redirect_response = RedirectResponse(redirect_url, status_code=302) + else: + # No parameters to add + redirect_response = RedirectResponse(next_url, status_code=302) # Copy headers and other attributes from 'response' to 'redirect_response' for header_name, header_value in response.headers.items(): @@ -1140,6 +1152,7 @@ def get_oauth_router( redirect_response.status_code = response.status_code if hasattr(response, "media_type"): redirect_response.media_type = response.media_type + return redirect_response return router diff --git a/backend/onyx/configs/constants.py b/backend/onyx/configs/constants.py index c47e4d6e5..d7951420f 100644 --- a/backend/onyx/configs/constants.py +++ b/backend/onyx/configs/constants.py @@ -76,6 +76,7 @@ KV_REINDEX_KEY = "needs_reindexing" KV_SEARCH_SETTINGS = "search_settings" KV_UNSTRUCTURED_API_KEY = "unstructured_api_key" KV_USER_STORE_KEY = "INVITED_USERS" +KV_PENDING_USERS_KEY = "PENDING_USERS" KV_NO_AUTH_USER_PREFERENCES_KEY = "no_auth_user_preferences" KV_CRED_KEY = "credential_id_{}" KV_GMAIL_CRED_KEY = "gmail_app_credential" diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index 484d24620..50f50d30c 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -2295,15 +2295,14 @@ class PublicBase(DeclarativeBase): __abstract__ = True +# Strictly keeps track of the tenant that a given user will authenticate to. class UserTenantMapping(Base): __tablename__ = "user_tenant_mapping" - __table_args__ = ( - UniqueConstraint("email", "tenant_id", name="uq_user_tenant"), - {"schema": "public"}, - ) + __table_args__ = ({"schema": "public"},) email: Mapped[str] = mapped_column(String, nullable=False, primary_key=True) - tenant_id: Mapped[str] = mapped_column(String, nullable=False) + tenant_id: Mapped[str] = mapped_column(String, nullable=False, primary_key=True) + active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) @validates("email") def validate_email(self, key: str, value: str) -> str: diff --git a/backend/onyx/server/manage/models.py b/backend/onyx/server/manage/models.py index cf51a7b08..cd877303d 100644 --- a/backend/onyx/server/manage/models.py +++ b/backend/onyx/server/manage/models.py @@ -53,6 +53,16 @@ class UserPreferences(BaseModel): temperature_override_enabled: bool | None = None +class TenantSnapshot(BaseModel): + tenant_id: str + number_of_users: int + + +class TenantInfo(BaseModel): + invitation: TenantSnapshot | None = None + new_tenant: TenantSnapshot | None = None + + class UserInfo(BaseModel): id: str email: str @@ -65,9 +75,10 @@ class UserInfo(BaseModel): current_token_created_at: datetime | None = None current_token_expiry_length: int | None = None is_cloud_superuser: bool = False - organization_name: str | None = None + team_name: str | None = None is_anonymous_user: bool | None = None password_configured: bool | None = None + tenant_info: TenantInfo | None = None @classmethod def from_model( @@ -76,8 +87,9 @@ class UserInfo(BaseModel): current_token_created_at: datetime | None = None, expiry_length: int | None = None, is_cloud_superuser: bool = False, - organization_name: str | None = None, + team_name: str | None = None, is_anonymous_user: bool | None = None, + tenant_info: TenantInfo | None = None, ) -> "UserInfo": return cls( id=str(user.id), @@ -99,7 +111,7 @@ class UserInfo(BaseModel): temperature_override_enabled=user.temperature_override_enabled, ) ), - organization_name=organization_name, + team_name=team_name, # set to None if TRACK_EXTERNAL_IDP_EXPIRY is False so that we avoid cases # where they previously had this set + used OIDC, and now they switched to # basic auth are now constantly getting redirected back to the login page @@ -109,6 +121,7 @@ class UserInfo(BaseModel): current_token_expiry_length=expiry_length, is_cloud_superuser=is_cloud_superuser, is_anonymous_user=is_anonymous_user, + tenant_info=tenant_info, ) diff --git a/backend/onyx/server/manage/users.py b/backend/onyx/server/manage/users.py index ad8f5098d..06243e5b3 100644 --- a/backend/onyx/server/manage/users.py +++ b/backend/onyx/server/manage/users.py @@ -12,13 +12,11 @@ from fastapi import Depends from fastapi import HTTPException from fastapi import Query from fastapi import Request -from psycopg2.errors import UniqueViolation from pydantic import BaseModel from sqlalchemy import Column from sqlalchemy import desc from sqlalchemy import select from sqlalchemy import update -from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from ee.onyx.configs.app_configs import SUPER_USERS @@ -55,6 +53,8 @@ from onyx.key_value_store.factory import get_kv_store from onyx.server.documents.models import PaginatedReturn from onyx.server.manage.models import AllUsersResponse from onyx.server.manage.models import AutoScrollRequest +from onyx.server.manage.models import TenantInfo +from onyx.server.manage.models import TenantSnapshot from onyx.server.manage.models import UserByEmail from onyx.server.manage.models import UserInfo from onyx.server.manage.models import UserPreferences @@ -296,13 +296,6 @@ def bulk_invite_users( "onyx.server.tenants.provisioning", "add_users_to_tenant", None )(new_invited_emails, tenant_id) - except IntegrityError as e: - if isinstance(e.orig, UniqueViolation): - raise HTTPException( - status_code=400, - detail="User has already been invited to a Onyx organization", - ) - raise except Exception as e: logger.error(f"Failed to add users to tenant {tenant_id}: {str(e)}") @@ -425,6 +418,10 @@ async def delete_user( db_session.expunge(user_to_delete) try: + tenant_id = get_current_tenant_id() + fetch_ee_implementation_or_noop( + "onyx.server.tenants.user_mapping", "remove_users_from_tenant", None + )([user_email.user_email], tenant_id) delete_user_from_db(user_to_delete, db_session) logger.info(f"Deleted user {user_to_delete.email}") @@ -553,8 +550,8 @@ def verify_user_logged_in( if anonymous_user_enabled(tenant_id=tenant_id): store = get_kv_store() return fetch_no_auth_user(store, anonymous_user_enabled=True) - raise BasicAuthenticationError(detail="User Not Authenticated") + if user.oidc_expiry and user.oidc_expiry < datetime.now(timezone.utc): raise BasicAuthenticationError( detail="Access denied. User's OIDC token has expired.", @@ -563,16 +560,35 @@ def verify_user_logged_in( token_created_at = ( None if MULTI_TENANT else get_current_token_creation(user, db_session) ) - organization_name = fetch_ee_implementation_or_noop( + + team_name = fetch_ee_implementation_or_noop( "onyx.server.tenants.user_mapping", "get_tenant_id_for_email", None )(user.email) + new_tenant: TenantSnapshot | None = None + tenant_invitation: TenantSnapshot | None = None + + if MULTI_TENANT: + if team_name != get_current_tenant_id(): + user_count = fetch_ee_implementation_or_noop( + "onyx.server.tenants.user_mapping", "get_tenant_count", None + )(team_name) + new_tenant = TenantSnapshot(tenant_id=team_name, number_of_users=user_count) + + tenant_invitation = fetch_ee_implementation_or_noop( + "onyx.server.tenants.user_mapping", "get_tenant_invitation", None + )(user.email) + user_info = UserInfo.from_model( user, current_token_created_at=token_created_at, expiry_length=SESSION_EXPIRE_TIME_SECONDS, is_cloud_superuser=user.email in SUPER_USERS, - organization_name=organization_name, + team_name=team_name, + tenant_info=TenantInfo( + new_tenant=new_tenant, + invitation=tenant_invitation, + ), ) return user_info diff --git a/backend/onyx/server/models.py b/backend/onyx/server/models.py index 0309fbf70..3e1b4a3ec 100644 --- a/backend/onyx/server/models.py +++ b/backend/onyx/server/models.py @@ -49,9 +49,9 @@ class FullUserSnapshot(BaseModel): ) -class InvitedUserSnapshot(BaseModel): - email: str - - class DisplayPriorityRequest(BaseModel): display_priority_map: dict[int, int] + + +class InvitedUserSnapshot(BaseModel): + email: str diff --git a/backend/onyx/tools/built_in_tools.py b/backend/onyx/tools/built_in_tools.py index 31adab6c4..5b2f8eab0 100644 --- a/backend/onyx/tools/built_in_tools.py +++ b/backend/onyx/tools/built_in_tools.py @@ -32,15 +32,15 @@ class InCodeToolInfo(TypedDict): BUILT_IN_TOOLS: list[InCodeToolInfo] = [ InCodeToolInfo( cls=SearchTool, - description="The Search Tool allows the Assistant to search through connected knowledge to help build an answer.", + description="The Search Action allows the Assistant to search through connected knowledge to help build an answer.", in_code_tool_id=SearchTool.__name__, display_name=SearchTool._DISPLAY_NAME, ), InCodeToolInfo( cls=ImageGenerationTool, description=( - "The Image Generation Tool allows the assistant to use DALL-E 3 to generate images. " - "The tool will be used when the user asks the assistant to generate an image." + "The Image Generation Action allows the assistant to use DALL-E 3 to generate images. " + "The action will be used when the user asks the assistant to generate an image." ), in_code_tool_id=ImageGenerationTool.__name__, display_name=ImageGenerationTool._DISPLAY_NAME, @@ -51,7 +51,7 @@ BUILT_IN_TOOLS: list[InCodeToolInfo] = [ InCodeToolInfo( cls=InternetSearchTool, description=( - "The Internet Search Tool allows the assistant " + "The Internet Search Action allows the assistant " "to perform internet searches for up-to-date information." ), in_code_tool_id=InternetSearchTool.__name__, @@ -98,7 +98,7 @@ def load_builtin_tools(db_session: Session) -> None: for tool_id, tool in list(in_code_tool_id_to_tool.items()): if tool_id not in built_in_ids: db_session.delete(tool) - logger.notice(f"Removed tool no longer in built-in list: {tool.name}") + logger.notice(f"Removed action no longer in built-in list: {tool.name}") db_session.commit() logger.notice("All built-in tools are loaded/verified.") diff --git a/backend/onyx/utils/url.py b/backend/onyx/utils/url.py new file mode 100644 index 000000000..3a0db980b --- /dev/null +++ b/backend/onyx/utils/url.py @@ -0,0 +1,43 @@ +from urllib.parse import parse_qs +from urllib.parse import urlencode +from urllib.parse import urlparse +from urllib.parse import urlunparse + + +def add_url_params(url: str, params: dict) -> str: + """ + Add parameters to a URL, handling existing parameters properly. + + Args: + url: The original URL + params: Dictionary of parameters to add + + Returns: + URL with added parameters + """ + # Parse the URL + parsed_url = urlparse(url) + + # Get existing query parameters + query_params = parse_qs(parsed_url.query) + + # Update with new parameters + for key, value in params.items(): + query_params[key] = [value] + + # Build the new query string + new_query = urlencode(query_params, doseq=True) + + # Reconstruct the URL with the new query string + new_url = urlunparse( + ( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + parsed_url.params, + new_query, + parsed_url.fragment, + ) + ) + + return new_url diff --git a/backend/tests/daily/connectors/confluence/test_confluence_basic.py b/backend/tests/daily/connectors/confluence/test_confluence_basic.py index 4da3e7e53..b675f3035 100644 --- a/backend/tests/daily/connectors/confluence/test_confluence_basic.py +++ b/backend/tests/daily/connectors/confluence/test_confluence_basic.py @@ -36,6 +36,7 @@ def confluence_connector() -> ConfluenceConnector: "onyx.file_processing.extract_file_text.get_unstructured_api_key", return_value=None, ) +@pytest.mark.skip(reason="Skipping this test") def test_confluence_connector_basic( mock_get_api_key: MagicMock, confluence_connector: ConfluenceConnector ) -> None: diff --git a/backend/tests/daily/connectors/confluence/test_confluence_permissions_basic.py b/backend/tests/daily/connectors/confluence/test_confluence_permissions_basic.py index 98fd56bb4..3bc01e7fa 100644 --- a/backend/tests/daily/connectors/confluence/test_confluence_permissions_basic.py +++ b/backend/tests/daily/connectors/confluence/test_confluence_permissions_basic.py @@ -28,6 +28,7 @@ def confluence_connector() -> ConfluenceConnector: # This should never fail because even if the docs in the cloud change, # the full doc ids retrieved should always be a subset of the slim doc ids +@pytest.mark.skip(reason="Skipping this test") def test_confluence_connector_permissions( confluence_connector: ConfluenceConnector, ) -> None: diff --git a/web/src/app/admin/tools/ToolEditor.tsx b/web/src/app/admin/actions/ActionEditor.tsx similarity index 96% rename from web/src/app/admin/tools/ToolEditor.tsx rename to web/src/app/admin/actions/ActionEditor.tsx index 02e437989..434edc969 100644 --- a/web/src/app/admin/tools/ToolEditor.tsx +++ b/web/src/app/admin/actions/ActionEditor.tsx @@ -2,14 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { - Formik, - Form, - Field, - ErrorMessage, - FieldArray, - ArrayHelpers, -} from "formik"; +import { Formik, Form, Field, ErrorMessage, FieldArray } from "formik"; import * as Yup from "yup"; import { MethodSpec, ToolSnapshot } from "@/lib/tools/interfaces"; import { TextFormField } from "@/components/admin/connectors/Field"; @@ -49,7 +42,7 @@ function prettifyDefinition(definition: any) { return JSON.stringify(definition, null, 2); } -function ToolForm({ +function ActionForm({ existingTool, values, setFieldValue, @@ -185,7 +178,7 @@ function ToolForm({ clipRule="evenodd" /> - Learn more about tool calling in our documentation + Learn more about actions in our documentation @@ -229,7 +222,7 @@ function ToolForm({ Custom Headers

- Specify custom headers for each request to this tool's API. + Specify custom headers for each request to this action's API.

- {existingTool ? "Update Tool" : "Create Tool"} + {existingTool ? "Update Action" : "Create Action"} @@ -386,7 +379,7 @@ const ToolSchema = Yup.object().shape({ passthrough_auth: Yup.boolean().default(false), }); -export function ToolEditor({ tool }: { tool?: ToolSnapshot }) { +export function ActionEditor({ tool }: { tool?: ToolSnapshot }) { const router = useRouter(); const { popup, setPopup } = usePopup(); const [definitionError, setDefinitionError] = useState(null); @@ -432,7 +425,7 @@ export function ToolEditor({ tool }: { tool?: ToolSnapshot }) { try { definition = parseJsonWithTrailingCommas(values.definition); } catch (error) { - setDefinitionError("Invalid JSON in tool definition"); + setDefinitionError("Invalid JSON in action definition"); return; } @@ -453,17 +446,17 @@ export function ToolEditor({ tool }: { tool?: ToolSnapshot }) { } if (response.error) { setPopup({ - message: "Failed to create tool - " + response.error, + message: "Failed to create action - " + response.error, type: "error", }); return; } - router.push(`/admin/tools?u=${Date.now()}`); + router.push(`/admin/actions?u=${Date.now()}`); }} > {({ isSubmitting, values, setFieldValue }) => { return ( -
- + Delete Tool diff --git a/web/src/app/admin/tools/new/page.tsx b/web/src/app/admin/actions/new/page.tsx similarity index 85% rename from web/src/app/admin/tools/new/page.tsx rename to web/src/app/admin/actions/new/page.tsx index 9146564e6..6029f88a6 100644 --- a/web/src/app/admin/tools/new/page.tsx +++ b/web/src/app/admin/actions/new/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { ToolEditor } from "@/app/admin/tools/ToolEditor"; +import { ActionEditor } from "@/app/admin/actions/ActionEditor"; import { BackButton } from "@/components/BackButton"; import { AdminPageTitle } from "@/components/admin/Title"; import { ToolIcon } from "@/components/icons/icons"; @@ -17,7 +17,7 @@ export default function NewToolPage() { /> - +
); diff --git a/web/src/app/admin/tools/page.tsx b/web/src/app/admin/actions/page.tsx similarity index 83% rename from web/src/app/admin/tools/page.tsx rename to web/src/app/admin/actions/page.tsx index d0b0de676..c124c39ec 100644 --- a/web/src/app/admin/tools/page.tsx +++ b/web/src/app/admin/actions/page.tsx @@ -1,4 +1,4 @@ -import { ToolsTable } from "./ToolsTable"; +import { ActionsTable } from "./ActionTable"; import { ToolSnapshot } from "@/lib/tools/interfaces"; import { FiPlusSquare } from "react-icons/fi"; import Link from "next/link"; @@ -33,19 +33,19 @@ export default async function Page() { /> - Tools allow assistants to retrieve information or take actions. + Actions allow assistants to retrieve information or take actions.
- Create a Tool + Create an Action - Existing Tools - + Existing Actions +
); diff --git a/web/src/app/admin/assistants/AssistantEditor.tsx b/web/src/app/admin/assistants/AssistantEditor.tsx index 69cc80088..4ed8c3be9 100644 --- a/web/src/app/admin/assistants/AssistantEditor.tsx +++ b/web/src/app/admin/assistants/AssistantEditor.tsx @@ -1095,8 +1095,7 @@ export function AssistantEditor({ {values.is_public ? (

- Anyone from your organization can view and use this - assistant + Anyone from your team can view and use this assistant

) : ( <> diff --git a/web/src/app/admin/assistants/PersonaTable.tsx b/web/src/app/admin/assistants/PersonaTable.tsx index 44f77f385..8f431b27a 100644 --- a/web/src/app/admin/assistants/PersonaTable.tsx +++ b/web/src/app/admin/assistants/PersonaTable.tsx @@ -177,6 +177,11 @@ export function PersonasTable() { entityName={personaToToggleDefault.name} onClose={closeDefaultModal} onSubmit={handleToggleDefault} + actionText={ + personaToToggleDefault.is_default_persona + ? "remove the featured status of" + : "set as featured" + } actionButtonText={ personaToToggleDefault.is_default_persona ? "Remove Featured" diff --git a/web/src/app/admin/configuration/document-processing/page.tsx b/web/src/app/admin/configuration/document-processing/page.tsx index 98240143c..368e60713 100644 --- a/web/src/app/admin/configuration/document-processing/page.tsx +++ b/web/src/app/admin/configuration/document-processing/page.tsx @@ -121,7 +121,7 @@ function Main() { ); } -function Page() { +export default function Page() { return (
); } - -export default Page; diff --git a/web/src/app/admin/token-rate-limits/page.tsx b/web/src/app/admin/token-rate-limits/page.tsx index 656e5b591..ad1fe9001 100644 --- a/web/src/app/admin/token-rate-limits/page.tsx +++ b/web/src/app/admin/token-rate-limits/page.tsx @@ -114,8 +114,8 @@ function Main() {
  • - Set a global rate limit to control your organization's overall - token spend. + Set a global rate limit to control your team's overall token + spend.
  • {isPaidEnterpriseFeaturesEnabled && ( diff --git a/web/src/app/admin/users/page.tsx b/web/src/app/admin/users/page.tsx index 78d7933e5..283c5c141 100644 --- a/web/src/app/admin/users/page.tsx +++ b/web/src/app/admin/users/page.tsx @@ -21,7 +21,8 @@ import { InvitedUserSnapshot } from "@/lib/types"; import { SearchBar } from "@/components/search/SearchBar"; import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal"; import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants"; - +import PendingUsersTable from "@/components/admin/users/PendingUsersTable"; +import { useUser } from "@/components/user/UserProvider"; const UsersTables = ({ q, setPopup, @@ -44,6 +45,15 @@ const UsersTables = ({ errorHandlingFetcher ); + const { + data: pendingUsers, + error: pendingUsersError, + isLoading: pendingUsersLoading, + mutate: pendingUsersMutate, + } = useSWR( + NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null, + errorHandlingFetcher + ); // Show loading animation only during the initial data fetch if (!validDomains) { return ; @@ -63,6 +73,9 @@ const UsersTables = ({ Current Users Invited Users + {NEXT_PUBLIC_CLOUD_ENABLED && ( + Pending Users + )} @@ -97,6 +110,25 @@ const UsersTables = ({ + {NEXT_PUBLIC_CLOUD_ENABLED && ( + + + + Pending Users + + + + + + + )} ); }; @@ -190,7 +222,7 @@ const AddUserButton = ({ entityName="your Access Logic" onClose={() => setShowConfirmation(false)} onSubmit={handleConfirmFirstInvite} - additionalDetails="After inviting the first user, only invited users will be able to join this platform. This is a security measure to control access to your instance." + additionalDetails="After inviting the first user, only invited users will be able to join this platform. This is a security measure to control access to your team." actionButtonText="Continue" variant="action" /> diff --git a/web/src/app/auth/create-account/page.tsx b/web/src/app/auth/create-account/page.tsx index 50cdf618c..358bdb101 100644 --- a/web/src/app/auth/create-account/page.tsx +++ b/web/src/app/auth/create-account/page.tsx @@ -18,8 +18,8 @@ const Page = () => { need to either:

      -
    • Be invited to an existing Onyx organization
    • -
    • Create a new Onyx organization
    • +
    • Be invited to an existing Onyx team
    • +
    • Create a new Onyx team
    ; +}) => { + const searchParams = await props.searchParams; + const nextUrl = Array.isArray(searchParams?.next) + ? searchParams?.next[0] + : searchParams?.next || null; + + const defaultEmail = Array.isArray(searchParams?.email) + ? searchParams?.email[0] + : searchParams?.email || null; + + const teamName = Array.isArray(searchParams?.team) + ? searchParams?.team[0] + : searchParams?.team || "your team"; + + // catch cases where the backend is completely unreachable here + // without try / catch, will just raise an exception and the page + // will not render + let authTypeMetadata: AuthTypeMetadata | null = null; + let currentUser: User | null = null; + try { + [authTypeMetadata, currentUser] = await Promise.all([ + getAuthTypeMetadataSS(), + getCurrentUserSS(), + ]); + } catch (e) { + console.log(`Some fetch failed for the login page - ${e}`); + } + + // simply take the user to the home page if Auth is disabled + if (authTypeMetadata?.authType === "disabled") { + return redirect("/chat"); + } + + // if user is already logged in, take them to the main app page + if (currentUser && currentUser.is_active && !currentUser.is_anonymous_user) { + if (!authTypeMetadata?.requiresVerification || currentUser.is_verified) { + return redirect("/chat"); + } + return redirect("/auth/waiting-on-verification"); + } + const cloud = authTypeMetadata?.authType === "cloud"; + + // only enable this page if basic login is enabled + if (authTypeMetadata?.authType !== "basic" && !cloud) { + return redirect("/chat"); + } + + let authUrl: string | null = null; + if (cloud && authTypeMetadata) { + authUrl = await getAuthUrlSS(authTypeMetadata.authType, null); + } + const emailDomain = defaultEmail?.split("@")[1]; + + return ( + + + + + <> +
    +
    +

    + Re-authenticate to join team +

    + + {cloud && authUrl && ( +
    + +
    +
    + or +
    +
    +
    + )} + + +
    + +
    + ); +}; + +export default Page; diff --git a/web/src/app/auth/login/EmailPasswordForm.tsx b/web/src/app/auth/login/EmailPasswordForm.tsx index fa8637503..0650f4ed5 100644 --- a/web/src/app/auth/login/EmailPasswordForm.tsx +++ b/web/src/app/auth/login/EmailPasswordForm.tsx @@ -13,6 +13,7 @@ import { set } from "lodash"; import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants"; import Link from "next/link"; import { useUser } from "@/components/user/UserProvider"; +import { useRouter } from "next/navigation"; export function EmailPasswordForm({ isSignup = false, @@ -20,15 +21,18 @@ export function EmailPasswordForm({ referralSource, nextUrl, defaultEmail, + isJoin = false, }: { isSignup?: boolean; shouldVerify?: boolean; referralSource?: string; nextUrl?: string | null; defaultEmail?: string | null; + isJoin?: boolean; }) { const { user } = useUser(); const { popup, setPopup } = usePopup(); + const router = useRouter(); const [isWorking, setIsWorking] = useState(false); return ( <> @@ -79,6 +83,11 @@ export function EmailPasswordForm({ }); setIsWorking(false); return; + } else { + setPopup({ + type: "success", + message: "Account created successfully. Please log in.", + }); } } @@ -92,7 +101,9 @@ export function EmailPasswordForm({ window.location.href = "/auth/waiting-on-verification"; } else { // See above comment - window.location.href = nextUrl ? encodeURI(nextUrl) : "/"; + window.location.href = nextUrl + ? encodeURI(nextUrl) + : `/chat${isSignup && !isJoin ? "?new_team=true" : ""}`; } } else { setIsWorking(false); @@ -135,11 +146,12 @@ export function EmailPasswordForm({ /> {user?.is_anonymous_user && ( -
    - - Create an account - - - {NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && ( + {NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && ( +
    Reset Password - )} -
    +
    + )}
    )} diff --git a/web/src/app/auth/login/SignInButton.tsx b/web/src/app/auth/login/SignInButton.tsx index 065c60de1..309c808f8 100644 --- a/web/src/app/auth/login/SignInButton.tsx +++ b/web/src/app/auth/login/SignInButton.tsx @@ -46,7 +46,7 @@ export function SignInButton({ return ( {button} diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 3071af72a..5e1d0c480 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -215,11 +215,7 @@ export function ChatPage({ const isInitialLoad = useRef(true); const [userSettingsToggled, setUserSettingsToggled] = useState(false); - const { - assistants: availableAssistants, - finalAssistants, - pinnedAssistants, - } = useAssistants(); + const { assistants: availableAssistants, pinnedAssistants } = useAssistants(); const [showApiKeyModal, setShowApiKeyModal] = useState( !shouldShowWelcomeModal @@ -229,7 +225,7 @@ export function ChatPage({ const slackChatId = searchParams.get("slackChatId"); const existingChatIdRaw = searchParams.get("chatId"); - const [showHistorySidebar, setShowHistorySidebar] = useState(false); // State to track if sidebar is open + const [showHistorySidebar, setShowHistorySidebar] = useState(false); const existingChatSessionId = existingChatIdRaw ? existingChatIdRaw : null; @@ -2451,7 +2447,7 @@ export function ChatPage({ h-full ${sidebarVisible ? "w-[200px]" : "w-[0px]"} `} - >
+ /> )} )} diff --git a/web/src/app/chat/modal/ShareChatSessionModal.tsx b/web/src/app/chat/modal/ShareChatSessionModal.tsx index 5b2bd8235..af2645918 100644 --- a/web/src/app/chat/modal/ShareChatSessionModal.tsx +++ b/web/src/app/chat/modal/ShareChatSessionModal.tsx @@ -117,9 +117,8 @@ export function ShareChatSessionModal({ {shareLink ? (
- This chat session is currently shared. Anyone in your - organization can view the message history using the following - link: + This chat session is currently shared. Anyone in your team can + view the message history using the following link:
@@ -160,7 +159,7 @@ export function ShareChatSessionModal({
Please make sure that all content in this chat is safe to - share with the whole organization. + share with the whole team.
diff --git a/web/src/app/ee/admin/whitelabeling/WhitelabelingForm.tsx b/web/src/app/ee/admin/whitelabeling/WhitelabelingForm.tsx index 70998dce3..a806117b7 100644 --- a/web/src/app/ee/admin/whitelabeling/WhitelabelingForm.tsx +++ b/web/src/app/ee/admin/whitelabeling/WhitelabelingForm.tsx @@ -140,7 +140,7 @@ export function WhitelabelingForm() { diff --git a/web/src/components/admin/ClientLayout.tsx b/web/src/components/admin/ClientLayout.tsx index c96c13860..8ce7d98d3 100644 --- a/web/src/components/admin/ClientLayout.tsx +++ b/web/src/components/admin/ClientLayout.tsx @@ -202,10 +202,10 @@ export function ClientLayout({ className="text-text-700" size={18} /> -
Tools
+
Actions
), - link: "/admin/tools", + link: "/admin/actions", }, ] : []), diff --git a/web/src/components/admin/connectors/AdminSidebar.tsx b/web/src/components/admin/connectors/AdminSidebar.tsx index 9e1506d9a..01b2103e4 100644 --- a/web/src/components/admin/connectors/AdminSidebar.tsx +++ b/web/src/components/admin/connectors/AdminSidebar.tsx @@ -2,19 +2,8 @@ "use client"; import React, { useContext } from "react"; import Link from "next/link"; -import { Logo } from "@/components/logo/Logo"; -import { NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED } from "@/lib/constants"; -import { HeaderTitle } from "@/components/header/HeaderTitle"; import { SettingsContext } from "@/components/settings/SettingsProvider"; -import { WarningCircle, WarningDiamond } from "@phosphor-icons/react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { CgArrowsExpandUpLeft } from "react-icons/cg"; -import LogoWithText from "@/components/header/LogoWithText"; import { LogoComponent } from "@/components/logo/FixedLogo"; interface Item { diff --git a/web/src/components/admin/users/PendingUsersTable.tsx b/web/src/components/admin/users/PendingUsersTable.tsx new file mode 100644 index 000000000..b7943ef2c --- /dev/null +++ b/web/src/components/admin/users/PendingUsersTable.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { PopupSpec } from "@/components/admin/connectors/Popup"; +import { + Table, + TableHead, + TableRow, + TableBody, + TableCell, +} from "@/components/ui/table"; +import CenteredPageSelector from "./CenteredPageSelector"; +import { ThreeDotsLoader } from "@/components/Loading"; +import { InvitedUserSnapshot } from "@/lib/types"; +import { TableHeader } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { ErrorCallout } from "@/components/ErrorCallout"; +import { FetchError } from "@/lib/fetcher"; +import { CheckIcon } from "lucide-react"; +import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal"; + +const USERS_PER_PAGE = 10; + +interface Props { + users: InvitedUserSnapshot[]; + setPopup: (spec: PopupSpec) => void; + mutate: () => void; + error: FetchError | null; + isLoading: boolean; + q: string; +} + +const PendingUsersTable = ({ + users, + setPopup, + mutate, + error, + isLoading, + q, +}: Props) => { + const [currentPageNum, setCurrentPageNum] = useState(1); + const [userToApprove, setUserToApprove] = useState(null); + + if (!users.length) + return

Users that have requested to join will show up here

; + + const totalPages = Math.ceil(users.length / USERS_PER_PAGE); + + // Filter users based on the search query + const filteredUsers = q + ? users.filter((user) => user.email.includes(q)) + : users; + + // Get the current page of users + const currentPageOfUsers = filteredUsers.slice( + (currentPageNum - 1) * USERS_PER_PAGE, + currentPageNum * USERS_PER_PAGE + ); + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + const handleAcceptRequest = async (email: string) => { + try { + await fetch("/api/tenants/users/invite/approve", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email }), + }); + mutate(); + setUserToApprove(null); + } catch (error) { + setPopup({ + type: "error", + message: "Failed to approve user request", + }); + } + }; + + return ( + <> + {userToApprove && ( + setUserToApprove(null)} + onSubmit={() => handleAcceptRequest(userToApprove)} + actionButtonText="Approve" + actionText="approve the join request of" + additionalDetails={`${userToApprove} has requested to join the team. Approving will add them as a user in this team.`} + variant="action" + accent + removeConfirmationText + /> + )} + + + + Email + +
Actions
+
+
+
+ + {currentPageOfUsers.length ? ( + currentPageOfUsers.map((user) => ( + + {user.email} + +
+ +
+
+
+ )) + ) : ( + + + {`No pending users found matching "${q}"`} + + + )} +
+
+ {totalPages > 1 ? ( + + ) : null} + + ); +}; + +export default PendingUsersTable; diff --git a/web/src/components/admin/users/buttons/LeaveOrganizationButton.tsx b/web/src/components/admin/users/buttons/LeaveOrganizationButton.tsx index 1af61de97..19c035d30 100644 --- a/web/src/components/admin/users/buttons/LeaveOrganizationButton.tsx +++ b/web/src/components/admin/users/buttons/LeaveOrganizationButton.tsx @@ -22,19 +22,19 @@ export const LeaveOrganizationButton = ({ }) => { const router = useRouter(); const { trigger, isMutating } = useSWRMutation( - "/api/tenants/leave-organization", + "/api/tenants/leave-team", userMutationFetcher, { onSuccess: () => { mutate(); setPopup({ - message: "Successfully left the organization!", + message: "Successfully left the team!", type: "success", }); }, onError: (errorMsg) => setPopup({ - message: `Unable to leave organization - ${errorMsg}`, + message: `Unable to leave team - ${errorMsg}`, type: "error", }), } @@ -53,11 +53,11 @@ export const LeaveOrganizationButton = ({ setShowLeaveModal(false)} onSubmit={handleLeaveOrganization} - additionalDetails="You will lose access to all organization data and resources." + additionalDetails="You will lose access to all team data and resources." /> )} diff --git a/web/src/components/auth/AuthErrorDisplay.tsx b/web/src/components/auth/AuthErrorDisplay.tsx index b16b130fd..cfbeb1ca0 100644 --- a/web/src/components/auth/AuthErrorDisplay.tsx +++ b/web/src/components/auth/AuthErrorDisplay.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { usePopup } from "../admin/connectors/Popup"; const ERROR_MESSAGES = { - Anonymous: "Your organization does not have anonymous access enabled.", + Anonymous: "Your team does not have anonymous access enabled.", }; export default function AuthErrorDisplay({ diff --git a/web/src/components/auth/AuthFlowContainer.tsx b/web/src/components/auth/AuthFlowContainer.tsx index 9b22a0596..04788df25 100644 --- a/web/src/components/auth/AuthFlowContainer.tsx +++ b/web/src/components/auth/AuthFlowContainer.tsx @@ -6,7 +6,7 @@ export default function AuthFlowContainer({ authState, }: { children: React.ReactNode; - authState?: "signup" | "login"; + authState?: "signup" | "login" | "join"; }) { return (
diff --git a/web/src/components/context/AppProvider.tsx b/web/src/components/context/AppProvider.tsx index c7f088922..93de3425b 100644 --- a/web/src/components/context/AppProvider.tsx +++ b/web/src/components/context/AppProvider.tsx @@ -6,6 +6,8 @@ import { SettingsProvider } from "../settings/SettingsProvider"; import { AssistantsProvider } from "./AssistantsContext"; import { Persona } from "@/app/admin/assistants/interfaces"; import { User } from "@/lib/types"; +import { ModalProvider } from "./ModalContext"; +import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants"; interface AppProviderProps { children: React.ReactNode; @@ -16,6 +18,8 @@ interface AppProviderProps { hasImageCompatibleModel: boolean; } +// + export const AppProvider = ({ children, user, @@ -33,7 +37,7 @@ export const AppProvider = ({ hasAnyConnectors={hasAnyConnectors} hasImageCompatibleModel={hasImageCompatibleModel} > - {children} + {children} diff --git a/web/src/components/context/ModalContext.tsx b/web/src/components/context/ModalContext.tsx new file mode 100644 index 000000000..407b0feec --- /dev/null +++ b/web/src/components/context/ModalContext.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback } from "react"; +import { NewTeamModal } from "../modals/NewTeamModal"; +import NewTenantModal from "../modals/NewTenantModal"; +import { User, NewTenantInfo } from "@/lib/types"; + +type ModalContextType = { + showNewTeamModal: boolean; + setShowNewTeamModal: (show: boolean) => void; + newTenantInfo: NewTenantInfo | null; + setNewTenantInfo: (info: NewTenantInfo | null) => void; + invitationInfo: NewTenantInfo | null; + setInvitationInfo: (info: NewTenantInfo | null) => void; +}; + +const ModalContext = createContext(undefined); + +export const useModalContext = () => { + const context = useContext(ModalContext); + if (context === undefined) { + throw new Error("useModalContext must be used within a ModalProvider"); + } + return context; +}; + +export const ModalProvider: React.FC<{ + children: React.ReactNode; + user: User | null; +}> = ({ children, user }) => { + const [showNewTeamModal, setShowNewTeamModal] = useState(false); + const [newTenantInfo, setNewTenantInfo] = useState( + user?.tenant_info?.new_tenant || null + ); + const [invitationInfo, setInvitationInfo] = useState( + user?.tenant_info?.invitation || null + ); + + // Initialize modal states based on user info + React.useEffect(() => { + if (user?.tenant_info?.new_tenant) { + setNewTenantInfo(user.tenant_info.new_tenant); + } + if (user?.tenant_info?.invitation) { + setInvitationInfo(user.tenant_info.invitation); + } + }, [user?.tenant_info]); + + // Render all application-wide modals + const renderModals = () => { + if (!user) return null; + + return ( + <> + {/* Modal for users to request to join an existing team */} + + + {/* Modal for users who've been accepted to a new team */} + {newTenantInfo && ( + setNewTenantInfo(null)} + /> + )} + + {/* Modal for users who've been invited to join a team */} + {invitationInfo && ( + setInvitationInfo(null)} + /> + )} + + ); + }; + + return ( + + {children} + {renderModals()} + + ); +}; diff --git a/web/src/components/modals/ConfirmEntityModal.tsx b/web/src/components/modals/ConfirmEntityModal.tsx index 213976f29..e0180462f 100644 --- a/web/src/components/modals/ConfirmEntityModal.tsx +++ b/web/src/components/modals/ConfirmEntityModal.tsx @@ -8,8 +8,11 @@ export const ConfirmEntityModal = ({ entityName, additionalDetails, actionButtonText, + actionText, includeCancelButton = true, variant = "delete", + accent = false, + removeConfirmationText = false, }: { entityType: string; entityName: string; @@ -17,23 +20,21 @@ export const ConfirmEntityModal = ({ onSubmit: () => void; additionalDetails?: string; actionButtonText?: string; + actionText?: string; includeCancelButton?: boolean; variant?: "delete" | "action"; + accent?: boolean; + removeConfirmationText?: boolean; }) => { const isDeleteVariant = variant === "delete"; const defaultButtonText = isDeleteVariant ? "Delete" : "Confirm"; const buttonText = actionButtonText || defaultButtonText; const getActionText = () => { - if (isDeleteVariant) { - return "delete"; - } - switch (entityType) { - case "Default Persona": - return "change the default status of"; - default: - return "modify"; + if (actionText) { + return actionText; } + return isDeleteVariant ? "delete" : "modify"; }; return ( @@ -44,9 +45,11 @@ export const ConfirmEntityModal = ({ {buttonText} {entityType}
-

- Are you sure you want to {getActionText()} {entityName}? -

+ {!removeConfirmationText && ( +

+ Are you sure you want to {getActionText()} {entityName}? +

+ )} {additionalDetails &&

{additionalDetails}

}
{includeCancelButton && ( @@ -56,7 +59,9 @@ export const ConfirmEntityModal = ({ )} diff --git a/web/src/components/modals/NewTeamModal.tsx b/web/src/components/modals/NewTeamModal.tsx new file mode 100644 index 000000000..ca6b0ac1c --- /dev/null +++ b/web/src/components/modals/NewTeamModal.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Dialog } from "@headlessui/react"; +import { Button } from "../ui/button"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { Building, ArrowRight, Send, CheckCircle } from "lucide-react"; +import { useUser } from "../user/UserProvider"; +import { useModalContext } from "../context/ModalContext"; + +interface TenantByDomainResponse { + tenant_id: string; + number_of_users: number; + creator_email: string; +} + +export function NewTeamModal() { + const { showNewTeamModal, setShowNewTeamModal } = useModalContext(); + const [existingTenant, setExistingTenant] = + useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [hasRequestedInvite, setHasRequestedInvite] = useState(false); + const [error, setError] = useState(null); + + const { user } = useUser(); + const appDomain = user?.email.split("@")[1]; + const router = useRouter(); + const searchParams = useSearchParams(); + const { setPopup } = usePopup(); + + useEffect(() => { + const hasNewTeamParam = searchParams.has("new_team"); + if (hasNewTeamParam) { + setShowNewTeamModal(true); + fetchTenantInfo(); + + // Remove the new_team parameter from the URL without page reload + const newParams = new URLSearchParams(searchParams.toString()); + newParams.delete("new_team"); + const newUrl = + window.location.pathname + + (newParams.toString() ? `?${newParams.toString()}` : ""); + window.history.replaceState({}, "", newUrl); + } + }, [searchParams, setShowNewTeamModal]); + + const fetchTenantInfo = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch("/api/tenants/existing-team-by-domain"); + if (!response.ok) { + throw new Error(`Failed to fetch team info: ${response.status}`); + } + const responseJson = await response.json(); + if (!responseJson) { + setShowNewTeamModal(false); + setExistingTenant(null); + return; + } + + const data = responseJson as TenantByDomainResponse; + setExistingTenant(data); + } catch (error) { + console.error("Failed to fetch tenant info:", error); + setError("Could not retrieve team information. Please try again later."); + } finally { + setIsLoading(false); + } + }; + + const handleRequestInvite = async () => { + if (!existingTenant) return; + + setIsSubmitting(true); + setError(null); + + try { + const response = await fetch("/api/tenants/users/invite/request", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ tenant_id: existingTenant.tenant_id }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to request invite"); + } + + setHasRequestedInvite(true); + setPopup({ + message: "Your invite request has been sent to the team admin.", + type: "success", + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to request an invite"; + setError(message); + setPopup({ + message, + type: "error", + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleContinueToNewOrg = () => { + const newUrl = window.location.pathname; + router.replace(newUrl); + setShowNewTeamModal(false); + }; + + // Update the close handler to use the context + const handleClose = () => { + setShowNewTeamModal(false); + }; + + // Only render if showNewTeamModal is true + if (!showNewTeamModal || isLoading) return null; + + return ( + + {/* Modal backdrop */} + + ); +} diff --git a/web/src/components/modals/NewTenantModal.tsx b/web/src/components/modals/NewTenantModal.tsx new file mode 100644 index 000000000..62d8aa65e --- /dev/null +++ b/web/src/components/modals/NewTenantModal.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState } from "react"; +import { Dialog } from "@headlessui/react"; +import { Button } from "../ui/button"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { ArrowRight, X } from "lucide-react"; +import { logout } from "@/lib/user"; +import { useUser } from "../user/UserProvider"; +import { NewTenantInfo } from "@/lib/types"; +import { useRouter } from "next/navigation"; + +// App domain should not be hardcoded +const APP_DOMAIN = process.env.NEXT_PUBLIC_APP_DOMAIN || "onyx.app"; + +interface NewTenantModalProps { + tenantInfo: NewTenantInfo; + isInvite?: boolean; + onClose?: () => void; +} + +export default function NewTenantModal({ + tenantInfo, + isInvite = false, + onClose, +}: NewTenantModalProps) { + const router = useRouter(); + const { setPopup } = usePopup(); + const { user } = useUser(); + const [isOpen, setIsOpen] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleClose = () => { + setIsOpen(false); + onClose?.(); + }; + + const handleJoinTenant = async () => { + setIsLoading(true); + setError(null); + + try { + if (isInvite) { + // Accept the invitation through the API + const response = await fetch("/api/tenants/users/invite/accept", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ tenant_id: tenantInfo.tenant_id }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to accept invitation"); + } + + setPopup({ + message: "You have accepted the invitation.", + type: "success", + }); + } else { + // For non-invite flow, just show success message + setPopup({ + message: "Processing your team join request...", + type: "success", + }); + } + + // Common logout and redirect for both flows + await logout(); + router.push(`/auth/join?email=${encodeURIComponent(user?.email || "")}`); + handleClose(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to join the team. Please try again."; + + setError(message); + setPopup({ + message, + type: "error", + }); + } finally { + setIsLoading(false); + } + }; + + const handleRejectInvite = async () => { + if (!isInvite) return; + + setIsLoading(true); + setError(null); + + try { + // Deny the invitation through the API + const response = await fetch("/api/tenants/users/invite/deny", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ tenant_id: tenantInfo.tenant_id }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to decline invitation"); + } + + setPopup({ + message: "You have declined the invitation.", + type: "info", + }); + handleClose(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to decline the invitation. Please try again."; + + setError(message); + setPopup({ + message, + type: "error", + }); + } finally { + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + return ( + + {/* Modal backdrop */} + + ); +} diff --git a/web/src/components/user/UserProvider.tsx b/web/src/components/user/UserProvider.tsx index 21135b2d6..621c9947e 100644 --- a/web/src/components/user/UserProvider.tsx +++ b/web/src/components/user/UserProvider.tsx @@ -76,8 +76,8 @@ export function UserProvider({ const identifyData: Record = { email: user.email, }; - if (user.organization_name) { - identifyData.organization_name = user.organization_name; + if (user.team_name) { + identifyData.team_name = user.team_name; } posthog.identify(user.id, identifyData); } else { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c667bc156..901aa85d7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -57,7 +57,7 @@ export interface User { current_token_expiry_length?: number; oidc_expiry?: Date; is_cloud_superuser?: boolean; - organization_name: string | null; + team_name: string | null; is_anonymous_user?: boolean; // If user does not have a configured password // (i.e.) they are using an oauth flow @@ -65,6 +65,17 @@ export interface User { // we don't want to show them things like the reset password // functionality password_configured?: boolean; + tenant_info?: TenantInfo | null; +} + +export interface TenantInfo { + new_tenant?: NewTenantInfo | null; + invitation?: NewTenantInfo | null; +} + +export interface NewTenantInfo { + tenant_id: string; + number_of_users: number; } export interface AllUsersResponse { diff --git a/web/src/lib/userSS.ts b/web/src/lib/userSS.ts index 67363b608..093c88a29 100644 --- a/web/src/lib/userSS.ts +++ b/web/src/lib/userSS.ts @@ -1,6 +1,6 @@ import { cookies } from "next/headers"; import { User } from "./types"; -import { buildUrl } from "./utilsSS"; +import { buildUrl, UrlBuilder } from "./utilsSS"; import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; import { AuthType, NEXT_PUBLIC_CLOUD_ENABLED } from "./constants"; @@ -55,13 +55,12 @@ export const getAuthDisabledSS = async (): Promise => { }; const getOIDCAuthUrlSS = async (nextUrl: string | null): Promise => { - const res = await fetch( - buildUrl( - `/auth/oidc/authorize${ - nextUrl ? `?next=${encodeURIComponent(nextUrl)}` : "" - }` - ) - ); + const url = UrlBuilder.fromInternalUrl("/auth/oidc/authorize"); + if (nextUrl) { + url.addParam("next", nextUrl); + } + + const res = await fetch(url.toString()); if (!res.ok) { throw new Error("Failed to fetch data"); } @@ -71,18 +70,16 @@ const getOIDCAuthUrlSS = async (nextUrl: string | null): Promise => { }; const getGoogleOAuthUrlSS = async (nextUrl: string | null): Promise => { - const res = await fetch( - buildUrl( - `/auth/oauth/authorize${ - nextUrl ? `?next=${encodeURIComponent(nextUrl)}` : "" - }` - ), - { - headers: { - cookie: processCookies(await cookies()), - }, - } - ); + const url = UrlBuilder.fromInternalUrl("/auth/oauth/authorize"); + if (nextUrl) { + url.addParam("next", nextUrl); + } + + const res = await fetch(url.toString(), { + headers: { + cookie: processCookies(await cookies()), + }, + }); if (!res.ok) { throw new Error("Failed to fetch data"); } @@ -92,13 +89,12 @@ const getGoogleOAuthUrlSS = async (nextUrl: string | null): Promise => { }; const getSAMLAuthUrlSS = async (nextUrl: string | null): Promise => { - const res = await fetch( - buildUrl( - `/auth/saml/authorize${ - nextUrl ? `?next=${encodeURIComponent(nextUrl)}` : "" - }` - ) - ); + const url = UrlBuilder.fromInternalUrl("/auth/saml/authorize"); + if (nextUrl) { + url.addParam("next", nextUrl); + } + + const res = await fetch(url.toString()); if (!res.ok) { throw new Error("Failed to fetch data"); } @@ -175,6 +171,7 @@ export const getCurrentUserSS = async (): Promise => { .join("; "), }, }); + if (!response.ok) { return null; } diff --git a/web/src/lib/utilsSS.ts b/web/src/lib/utilsSS.ts index b54bb1a2b..889db8b6f 100644 --- a/web/src/lib/utilsSS.ts +++ b/web/src/lib/utilsSS.ts @@ -15,6 +15,47 @@ export function buildUrl(path: string) { return `${INTERNAL_URL}/${path}`; } +export class UrlBuilder { + private url: URL; + + constructor(baseUrl: string) { + try { + this.url = new URL(baseUrl); + } catch (e) { + // Handle relative URLs by prepending a base + this.url = new URL(baseUrl, "http://placeholder.com"); + } + } + + addParam(key: string, value: string | number | boolean): UrlBuilder { + this.url.searchParams.set(key, String(value)); + return this; + } + + addParams(params: Record): UrlBuilder { + Object.entries(params).forEach(([key, value]) => { + this.url.searchParams.set(key, String(value)); + }); + return this; + } + + toString(): string { + // Extract just the path and query parts for relative URLs + if (this.url.origin === "http://placeholder.com") { + return `${this.url.pathname}${this.url.search}`; + } + return this.url.toString(); + } + + static fromInternalUrl(path: string): UrlBuilder { + return new UrlBuilder(buildUrl(path)); + } + + static fromClientUrl(path: string): UrlBuilder { + return new UrlBuilder(buildClientUrl(path)); + } +} + export async function fetchSS(url: string, options?: RequestInit) { const init = options || { credentials: "include",