diff --git a/backend/Dockerfile b/backend/Dockerfile index 17f93979955..f7ea1e3e1d4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -92,6 +92,7 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY ./danswer /app/danswer COPY ./shared_configs /app/shared_configs COPY ./alembic /app/alembic +COPY ./alembic_tenants /app/alembic_tenants COPY ./alembic.ini /app/alembic.ini COPY supervisord.conf /usr/etc/supervisord.conf diff --git a/backend/danswer/auth/users.py b/backend/danswer/auth/users.py index 3fc117b31a0..983e17182b0 100644 --- a/backend/danswer/auth/users.py +++ b/backend/danswer/auth/users.py @@ -10,6 +10,7 @@ import jwt from email_validator import EmailNotValidError +from email_validator import EmailUndeliverableError from email_validator import validate_email from fastapi import APIRouter from fastapi import Depends @@ -41,10 +42,8 @@ from danswer.auth.schemas import UserRole from danswer.auth.schemas import UserUpdate from danswer.configs.app_configs import AUTH_TYPE -from danswer.configs.app_configs import DATA_PLANE_SECRET from danswer.configs.app_configs import DISABLE_AUTH from danswer.configs.app_configs import EMAIL_FROM -from danswer.configs.app_configs import EXPECTED_API_KEY from danswer.configs.app_configs import MULTI_TENANT from danswer.configs.app_configs import REQUIRE_EMAIL_VERIFICATION from danswer.configs.app_configs import SECRET_JWT_KEY @@ -129,7 +128,10 @@ def verify_email_is_invited(email: str) -> None: if not email: raise PermissionError("Email must be specified") - email_info = validate_email(email) # can raise EmailNotValidError + try: + email_info = validate_email(email) + except EmailUndeliverableError: + raise PermissionError("Email is not valid") for email_whitelist in whitelist: try: @@ -652,28 +654,3 @@ async def current_admin_user(user: User | None = Depends(current_user)) -> User def get_default_admin_user_emails_() -> list[str]: # No default seeding available for Danswer MIT return [] - - -async def control_plane_dep(request: Request) -> None: - api_key = request.headers.get("X-API-KEY") - if api_key != EXPECTED_API_KEY: - logger.warning("Invalid API key") - raise HTTPException(status_code=401, detail="Invalid API key") - - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - logger.warning("Invalid authorization header") - raise HTTPException(status_code=401, detail="Invalid authorization header") - - token = auth_header.split(" ")[1] - try: - payload = jwt.decode(token, DATA_PLANE_SECRET, algorithms=["HS256"]) - if payload.get("scope") != "tenant:create": - logger.warning("Insufficient permissions") - raise HTTPException(status_code=403, detail="Insufficient permissions") - except jwt.ExpiredSignatureError: - logger.warning("Token has expired") - raise HTTPException(status_code=401, detail="Token has expired") - except jwt.InvalidTokenError: - logger.warning("Invalid token") - raise HTTPException(status_code=401, detail="Invalid token") diff --git a/backend/danswer/configs/app_configs.py b/backend/danswer/configs/app_configs.py index 04925262196..a061b79019f 100644 --- a/backend/danswer/configs/app_configs.py +++ b/backend/danswer/configs/app_configs.py @@ -423,11 +423,27 @@ AZURE_DALLE_DEPLOYMENT_NAME = os.environ.get("AZURE_DALLE_DEPLOYMENT_NAME") +# Cloud configuration + +# Multi-tenancy configuration MULTI_TENANT = os.environ.get("MULTI_TENANT", "").lower() == "true" -SECRET_JWT_KEY = os.environ.get("SECRET_JWT_KEY", "") +ENABLE_EMAIL_INVITES = os.environ.get("ENABLE_EMAIL_INVITES", "").lower() == "true" +# Security and authentication +SECRET_JWT_KEY = os.environ.get( + "SECRET_JWT_KEY", "" +) # Used for encryption of the JWT token for user's tenant context +DATA_PLANE_SECRET = os.environ.get( + "DATA_PLANE_SECRET", "" +) # Used for secure communication between the control and data plane +EXPECTED_API_KEY = os.environ.get( + "EXPECTED_API_KEY", "" +) # Additional security check for the control plane API -DATA_PLANE_SECRET = os.environ.get("DATA_PLANE_SECRET", "") -EXPECTED_API_KEY = os.environ.get("EXPECTED_API_KEY", "") +# API configuration +CONTROL_PLANE_API_BASE_URL = os.environ.get( + "CONTROL_PLANE_API_BASE_URL", "http://localhost:8082" +) -ENABLE_EMAIL_INVITES = os.environ.get("ENABLE_EMAIL_INVITES", "").lower() == "true" +# JWT configuration +JWT_ALGORITHM = "HS256" diff --git a/backend/danswer/db/auth.py b/backend/danswer/db/auth.py index dc3f5a837bd..9eba3806df5 100644 --- a/backend/danswer/db/auth.py +++ b/backend/danswer/db/auth.py @@ -10,7 +10,9 @@ from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from sqlalchemy.orm import Session +from danswer.auth.invited_users import get_invited_users from danswer.auth.schemas import UserRole from danswer.db.engine import get_async_session from danswer.db.engine import get_async_session_with_tenant @@ -33,10 +35,20 @@ def get_default_admin_user_emails() -> list[str]: return get_default_admin_user_emails_fn() +def get_total_users(db_session: Session) -> int: + """ + Returns the total number of users in the system. + This is the sum of users and invited users. + """ + user_count = db_session.query(User).count() + invited_users = len(get_invited_users()) + return user_count + invited_users + + async def get_user_count() -> int: - async with get_async_session_with_tenant() as asession: + async with get_async_session_with_tenant() as session: stmt = select(func.count(User.id)) - result = await asession.execute(stmt) + result = await session.execute(stmt) user_count = result.scalar() if user_count is None: raise RuntimeError("Was not able to fetch the user count.") diff --git a/backend/danswer/document_index/vespa/index.py b/backend/danswer/document_index/vespa/index.py index cb7bdccd730..cdf58ec4277 100644 --- a/backend/danswer/document_index/vespa/index.py +++ b/backend/danswer/document_index/vespa/index.py @@ -189,7 +189,10 @@ def ensure_indices_exist( schema = schema_template.replace( DANSWER_CHUNK_REPLACEMENT_PAT, self.index_name ).replace(VESPA_DIM_REPLACEMENT_PAT, str(index_embedding_dim)) + schema = schema.replace(TENANT_ID_PAT, "") + schema = add_ngrams_to_schema(schema) if needs_reindexing else schema + zip_dict[f"schemas/{schema_names[0]}.sd"] = schema.encode("utf-8") if self.secondary_index_name: diff --git a/backend/danswer/server/auth_check.py b/backend/danswer/server/auth_check.py index c79b9ad0967..69aede4241f 100644 --- a/backend/danswer/server/auth_check.py +++ b/backend/danswer/server/auth_check.py @@ -4,13 +4,13 @@ from fastapi.dependencies.models import Dependant from starlette.routing import BaseRoute -from danswer.auth.users import control_plane_dep from danswer.auth.users import current_admin_user from danswer.auth.users import current_curator_or_admin_user from danswer.auth.users import current_user from danswer.auth.users import current_user_with_expired_token from danswer.configs.app_configs import APP_API_PREFIX from danswer.server.danswer_api.ingestion import api_key_dep +from ee.danswer.server.tenants.access import control_plane_dep PUBLIC_ENDPOINT_SPECS = [ diff --git a/backend/danswer/server/manage/users.py b/backend/danswer/server/manage/users.py index 0614a4beb85..ae2ab8c6e8c 100644 --- a/backend/danswer/server/manage/users.py +++ b/backend/danswer/server/manage/users.py @@ -3,6 +3,8 @@ from datetime import timezone import jwt +from email_validator import EmailNotValidError +from email_validator import EmailUndeliverableError from email_validator import validate_email from fastapi import APIRouter from fastapi import Body @@ -35,6 +37,7 @@ from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS from danswer.configs.app_configs import VALID_EMAIL_DOMAINS from danswer.configs.constants import AuthType +from danswer.db.auth import get_total_users from danswer.db.engine import current_tenant_id from danswer.db.engine import get_session from danswer.db.models import AccessToken @@ -60,6 +63,7 @@ from ee.danswer.db.api_key import is_api_key_email_address from ee.danswer.db.external_perm import delete_user__ext_group_for_user__no_commit from ee.danswer.db.user_group import remove_curator_status__no_commit +from ee.danswer.server.tenants.billing import register_tenant_users from ee.danswer.server.tenants.provisioning import add_users_to_tenant from ee.danswer.server.tenants.provisioning import remove_users_from_tenant @@ -174,19 +178,29 @@ def list_all_users( def bulk_invite_users( emails: list[str] = Body(..., embed=True), current_user: User | None = Depends(current_admin_user), + db_session: Session = Depends(get_session), ) -> int: """emails are string validated. If any email fails validation, no emails are invited and an exception is raised.""" + if current_user is None: raise HTTPException( status_code=400, detail="Auth is disabled, cannot invite users" ) + tenant_id = current_tenant_id.get() normalized_emails = [] - for email in emails: - email_info = validate_email(email) # can raise EmailNotValidError - normalized_emails.append(email_info.normalized) # type: ignore + try: + for email in emails: + email_info = validate_email(email) + normalized_emails.append(email_info.normalized) # type: ignore + + except (EmailUndeliverableError, EmailNotValidError): + raise HTTPException( + status_code=400, + detail="One or more emails in the list are invalid", + ) if MULTI_TENANT: try: @@ -199,30 +213,58 @@ def bulk_invite_users( ) raise - all_emails = list(set(normalized_emails) | set(get_invited_users())) + initial_invited_users = get_invited_users() - if MULTI_TENANT and ENABLE_EMAIL_INVITES: - try: - for email in all_emails: - send_user_email_invite(email, current_user) - except Exception as e: - logger.error(f"Error sending email invite to invited users: {e}") + all_emails = list(set(normalized_emails) | set(initial_invited_users)) + number_of_invited_users = write_invited_users(all_emails) - return write_invited_users(all_emails) + if not MULTI_TENANT: + return number_of_invited_users + try: + logger.info("Registering tenant users") + register_tenant_users(current_tenant_id.get(), get_total_users(db_session)) + if ENABLE_EMAIL_INVITES: + try: + for email in all_emails: + send_user_email_invite(email, current_user) + except Exception as e: + logger.error(f"Error sending email invite to invited users: {e}") + + return number_of_invited_users + except Exception as e: + logger.error(f"Failed to register tenant users: {str(e)}") + logger.info( + "Reverting changes: removing users from tenant and resetting invited users" + ) + write_invited_users(initial_invited_users) # Reset to original state + remove_users_from_tenant(normalized_emails, tenant_id) + raise e @router.patch("/manage/admin/remove-invited-user") def remove_invited_user( user_email: UserByEmail, _: User | None = Depends(current_admin_user), + db_session: Session = Depends(get_session), ) -> int: user_emails = get_invited_users() remaining_users = [user for user in user_emails if user != user_email.user_email] tenant_id = current_tenant_id.get() remove_users_from_tenant([user_email.user_email], tenant_id) + number_of_invited_users = write_invited_users(remaining_users) + + try: + if MULTI_TENANT: + register_tenant_users(current_tenant_id.get(), get_total_users(db_session)) + except Exception: + logger.error( + "Request to update number of seats taken in control plane failed. " + "This may cause synchronization issues/out of date enforcement of seat limits." + ) + raise - return write_invited_users(remaining_users) + return number_of_invited_users @router.patch("/manage/admin/deactivate-user") @@ -421,7 +463,6 @@ def get_current_token_creation( @router.get("/me") def verify_user_logged_in( - request: Request, user: User | None = Depends(optional_user), db_session: Session = Depends(get_session), ) -> UserInfo: diff --git a/backend/danswer/server/settings/models.py b/backend/danswer/server/settings/models.py index ae7e7236c8d..6713f7f67e8 100644 --- a/backend/danswer/server/settings/models.py +++ b/backend/danswer/server/settings/models.py @@ -12,6 +12,12 @@ class PageType(str, Enum): SEARCH = "search" +class GatingType(str, Enum): + FULL = "full" # Complete restriction of access to the product or service + PARTIAL = "partial" # Full access but warning (no credit card on file) + NONE = "none" # No restrictions, full access to all features + + class Notification(BaseModel): id: int notif_type: NotificationType @@ -38,6 +44,7 @@ class Settings(BaseModel): default_page: PageType = PageType.SEARCH maximum_chat_retention_days: int | None = None gpu_enabled: bool | None = None + product_gating: GatingType = GatingType.NONE def check_validity(self) -> None: chat_page_enabled = self.chat_page_enabled diff --git a/backend/danswer/server/utils.py b/backend/danswer/server/utils.py index 70404537f70..68e6dc8d0b8 100644 --- a/backend/danswer/server/utils.py +++ b/backend/danswer/server/utils.py @@ -3,6 +3,7 @@ from datetime import datetime from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from textwrap import dedent from typing import Any from danswer.configs.app_configs import SMTP_PASS @@ -58,22 +59,25 @@ def mask_credential_dict(credential_dict: dict[str, Any]) -> dict[str, str]: def send_user_email_invite(user_email: str, current_user: User) -> None: msg = MIMEMultipart() msg["Subject"] = "Invitation to Join Danswer Workspace" - msg["To"] = user_email msg["From"] = current_user.email + msg["To"] = user_email - email_body = f""" -Hello, + email_body = dedent( + f"""\ + Hello, -You have been invited to join a workspace on Danswer. + You have been invited to join a workspace on Danswer. -To join the workspace, please do so at the following link: -{WEB_DOMAIN}/auth/login + To join the workspace, please visit the following link: -Best regards, -The Danswer Team""" + {WEB_DOMAIN}/auth/login - msg.attach(MIMEText(email_body, "plain")) + Best regards, + The Danswer Team + """ + ) + msg.attach(MIMEText(email_body, "plain")) with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as smtp_server: smtp_server.starttls() smtp_server.login(SMTP_USER, SMTP_PASS) diff --git a/backend/ee/danswer/configs/app_configs.py b/backend/ee/danswer/configs/app_configs.py index 1430a499136..2c782241076 100644 --- a/backend/ee/danswer/configs/app_configs.py +++ b/backend/ee/danswer/configs/app_configs.py @@ -21,3 +21,7 @@ # Auto Permission Sync ##### NUM_PERMISSION_WORKERS = int(os.environ.get("NUM_PERMISSION_WORKERS") or 2) + + +STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY") +STRIPE_PRICE_ID = os.environ.get("STRIPE_PRICE") diff --git a/backend/ee/danswer/main.py b/backend/ee/danswer/main.py index e6483f75ae1..4584e06a00b 100644 --- a/backend/ee/danswer/main.py +++ b/backend/ee/danswer/main.py @@ -85,8 +85,6 @@ def get_application() -> FastAPI: # RBAC / group access control include_router_with_global_prefix_prepended(application, user_group_router) - # Tenant management - include_router_with_global_prefix_prepended(application, tenants_router) # Analytics endpoints include_router_with_global_prefix_prepended(application, analytics_router) include_router_with_global_prefix_prepended(application, query_history_router) @@ -107,6 +105,10 @@ def get_application() -> FastAPI: include_router_with_global_prefix_prepended(application, enterprise_settings_router) include_router_with_global_prefix_prepended(application, usage_export_router) + if MULTI_TENANT: + # Tenant management + include_router_with_global_prefix_prepended(application, tenants_router) + # Ensure all routes have auth enabled or are explicitly marked as public check_ee_router_auth(application) diff --git a/backend/ee/danswer/server/tenants/access.py b/backend/ee/danswer/server/tenants/access.py index e69de29bb2d..255e6c0ea94 100644 --- a/backend/ee/danswer/server/tenants/access.py +++ b/backend/ee/danswer/server/tenants/access.py @@ -0,0 +1,53 @@ +from datetime import datetime +from datetime import timedelta + +import jwt +from fastapi import HTTPException +from fastapi import Request + +from danswer.configs.app_configs import DATA_PLANE_SECRET +from danswer.configs.app_configs import EXPECTED_API_KEY +from danswer.configs.app_configs import JWT_ALGORITHM +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +def generate_data_plane_token() -> str: + if DATA_PLANE_SECRET is None: + raise ValueError("DATA_PLANE_SECRET is not set") + + payload = { + "iss": "data_plane", + "exp": datetime.utcnow() + timedelta(minutes=5), + "iat": datetime.utcnow(), + "scope": "api_access", + } + + token = jwt.encode(payload, DATA_PLANE_SECRET, algorithm=JWT_ALGORITHM) + return token + + +async def control_plane_dep(request: Request) -> None: + api_key = request.headers.get("X-API-KEY") + if api_key != EXPECTED_API_KEY: + logger.warning("Invalid API key") + raise HTTPException(status_code=401, detail="Invalid API key") + + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + logger.warning("Invalid authorization header") + raise HTTPException(status_code=401, detail="Invalid authorization header") + + token = auth_header.split(" ")[1] + try: + payload = jwt.decode(token, DATA_PLANE_SECRET, algorithms=[JWT_ALGORITHM]) + if payload.get("scope") != "tenant:create": + logger.warning("Insufficient permissions") + raise HTTPException(status_code=403, detail="Insufficient permissions") + except jwt.ExpiredSignatureError: + logger.warning("Token has expired") + raise HTTPException(status_code=401, detail="Token has expired") + except jwt.InvalidTokenError: + logger.warning("Invalid token") + raise HTTPException(status_code=401, detail="Invalid token") diff --git a/backend/ee/danswer/server/tenants/api.py b/backend/ee/danswer/server/tenants/api.py index b522112ae06..89602e7eaa5 100644 --- a/backend/ee/danswer/server/tenants/api.py +++ b/backend/ee/danswer/server/tenants/api.py @@ -1,19 +1,33 @@ +import stripe from fastapi import APIRouter from fastapi import Depends from fastapi import HTTPException -from danswer.auth.users import control_plane_dep +from danswer.auth.users import current_admin_user +from danswer.auth.users import User from danswer.configs.app_configs import MULTI_TENANT +from danswer.configs.app_configs import WEB_DOMAIN from danswer.db.engine import get_session_with_tenant +from danswer.server.settings.store import load_settings +from danswer.server.settings.store import store_settings from danswer.setup import setup_danswer from danswer.utils.logger import setup_logger +from ee.danswer.configs.app_configs import STRIPE_SECRET_KEY +from ee.danswer.server.tenants.access import control_plane_dep +from ee.danswer.server.tenants.billing import fetch_billing_information +from ee.danswer.server.tenants.billing import fetch_tenant_stripe_information +from ee.danswer.server.tenants.models import BillingInformation from ee.danswer.server.tenants.models import CreateTenantRequest +from ee.danswer.server.tenants.models import ProductGatingRequest from ee.danswer.server.tenants.provisioning import add_users_to_tenant from ee.danswer.server.tenants.provisioning import ensure_schema_exists from ee.danswer.server.tenants.provisioning import run_alembic_migrations from ee.danswer.server.tenants.provisioning import user_owns_a_tenant from shared_configs.configs import current_tenant_id + +stripe.api_key = STRIPE_SECRET_KEY + logger = setup_logger() router = APIRouter(prefix="/tenants") @@ -22,30 +36,30 @@ def create_tenant( create_tenant_request: CreateTenantRequest, _: None = Depends(control_plane_dep) ) -> dict[str, str]: + if not MULTI_TENANT: + raise HTTPException(status_code=403, detail="Multi-tenancy is not enabled") + tenant_id = create_tenant_request.tenant_id email = create_tenant_request.initial_admin_email token = None + if user_owns_a_tenant(email): raise HTTPException( status_code=409, detail="User already belongs to an organization" ) try: - if not MULTI_TENANT: - raise HTTPException(status_code=403, detail="Multi-tenancy is not enabled") - if not ensure_schema_exists(tenant_id): logger.info(f"Created schema for tenant {tenant_id}") else: logger.info(f"Schema already exists for tenant {tenant_id}") - run_alembic_migrations(tenant_id) token = current_tenant_id.set(tenant_id) - print("getting session", tenant_id) + run_alembic_migrations(tenant_id) + with get_session_with_tenant(tenant_id) as db_session: setup_danswer(db_session) - logger.info(f"Tenant {tenant_id} created successfully") add_users_to_tenant([email], tenant_id) return { @@ -60,3 +74,51 @@ def create_tenant( finally: if token is not None: current_tenant_id.reset(token) + + +@router.post("/product-gating") +def gate_product( + product_gating_request: ProductGatingRequest, _: None = Depends(control_plane_dep) +) -> None: + """ + 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 + 1) User has ended free trial without adding payment method + 2) User's card has declined + """ + token = current_tenant_id.set(current_tenant_id.get()) + + settings = load_settings() + settings.product_gating = product_gating_request.product_gating + store_settings(settings) + + if token is not None: + current_tenant_id.reset(token) + + +@router.get("/billing-information", response_model=BillingInformation) +async def billing_information( + _: User = Depends(current_admin_user), +) -> BillingInformation: + logger.info("Fetching billing information") + return BillingInformation(**fetch_billing_information(current_tenant_id.get())) + + +@router.post("/create-customer-portal-session") +async def create_customer_portal_session(_: User = Depends(current_admin_user)) -> dict: + try: + # Fetch tenant_id and current tenant's information + tenant_id = current_tenant_id.get() + stripe_info = fetch_tenant_stripe_information(tenant_id) + stripe_customer_id = stripe_info.get("stripe_customer_id") + logger.info(stripe_customer_id) + portal_session = stripe.billing_portal.Session.create( + customer=stripe_customer_id, + return_url=f"{WEB_DOMAIN}/admin/cloud-settings", + ) + 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)) diff --git a/backend/ee/danswer/server/tenants/billing.py b/backend/ee/danswer/server/tenants/billing.py new file mode 100644 index 00000000000..5ff4a4f096b --- /dev/null +++ b/backend/ee/danswer/server/tenants/billing.py @@ -0,0 +1,67 @@ +from typing import cast + +import requests +import stripe + +from danswer.configs.app_configs import CONTROL_PLANE_API_BASE_URL +from danswer.utils.logger import setup_logger +from ee.danswer.configs.app_configs import STRIPE_PRICE_ID +from ee.danswer.configs.app_configs import STRIPE_SECRET_KEY +from ee.danswer.server.tenants.access import generate_data_plane_token +from shared_configs.configs import current_tenant_id + +stripe.api_key = STRIPE_SECRET_KEY + +logger = setup_logger() + + +def fetch_tenant_stripe_information(tenant_id: str) -> dict: + token = generate_data_plane_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + url = f"{CONTROL_PLANE_API_BASE_URL}/tenant-stripe-information" + params = {"tenant_id": tenant_id} + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + + +def fetch_billing_information(tenant_id: str) -> dict: + logger.info("Fetching billing information") + token = generate_data_plane_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + url = f"{CONTROL_PLANE_API_BASE_URL}/billing-information" + params = {"tenant_id": tenant_id} + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + billing_info = response.json() + return billing_info + + +def register_tenant_users(tenant_id: str, number_of_users: int) -> stripe.Subscription: + """ + Send a request to the control service to register the number of users for a tenant. + """ + + tenant_id = current_tenant_id.get() + response = fetch_tenant_stripe_information(tenant_id) + stripe_subscription_id = cast(str, response.get("stripe_subscription_id")) + + subscription = stripe.Subscription.retrieve(stripe_subscription_id) + updated_subscription = stripe.Subscription.modify( + stripe_subscription_id, + items=[ + { + "id": subscription["items"]["data"][0].id, + "price": STRIPE_PRICE_ID, + "quantity": number_of_users, + } + ], + metadata={"tenant_id": str(tenant_id)}, + ) + return updated_subscription diff --git a/backend/ee/danswer/server/tenants/models.py b/backend/ee/danswer/server/tenants/models.py index 833650c42a6..32642ecfcda 100644 --- a/backend/ee/danswer/server/tenants/models.py +++ b/backend/ee/danswer/server/tenants/models.py @@ -1,6 +1,29 @@ from pydantic import BaseModel +from danswer.server.settings.models import GatingType + + +class CheckoutSessionCreationRequest(BaseModel): + quantity: int + class CreateTenantRequest(BaseModel): tenant_id: str initial_admin_email: str + + +class ProductGatingRequest(BaseModel): + tenant_id: str + product_gating: GatingType + + +class BillingInformation(BaseModel): + seats: int + subscription_status: str + billing_start: str + billing_end: str + payment_method_enabled: bool + + +class CheckoutSessionCreationResponse(BaseModel): + id: str diff --git a/deployment/docker_compose/docker-compose.prod-cloud.yml b/deployment/docker_compose/docker-compose.prod-cloud.yml new file mode 100644 index 00000000000..392d7c67ad4 --- /dev/null +++ b/deployment/docker_compose/docker-compose.prod-cloud.yml @@ -0,0 +1,243 @@ +services: + api_server: + image: danswer/danswer-backend:${IMAGE_TAG:-latest} + build: + context: ../../backend + dockerfile: Dockerfile.cloud + command: > + /bin/sh -c "alembic -n schema_private upgrade head && + echo \"Starting Danswer Api Server\" && + uvicorn danswer.main:app --host 0.0.0.0 --port 8080" + depends_on: + - relational_db + - index + - cache + - inference_model_server + restart: always + env_file: + - .env + environment: + - AUTH_TYPE=${AUTH_TYPE:-oidc} + - POSTGRES_HOST=relational_db + - VESPA_HOST=index + - REDIS_HOST=cache + - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} + extra_hosts: + - "host.docker.internal:host-gateway" + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + + + background: + image: danswer/danswer-backend:${IMAGE_TAG:-latest} + build: + context: ../../backend + dockerfile: Dockerfile + command: /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf + depends_on: + - relational_db + - index + - cache + - inference_model_server + - indexing_model_server + restart: always + env_file: + - .env + environment: + - AUTH_TYPE=${AUTH_TYPE:-oidc} + - POSTGRES_HOST=relational_db + - VESPA_HOST=index + - REDIS_HOST=cache + - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} + - INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server} + extra_hosts: + - "host.docker.internal:host-gateway" + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + + web_server: + image: danswer/danswer-web-server:${IMAGE_TAG:-latest} + build: + context: ../../web + dockerfile: Dockerfile + args: + - NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING:-false} + - NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA:-false} + - NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-} + - NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-} + - NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-} + - NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-} + depends_on: + - api_server + restart: always + env_file: + - .env + environment: + - INTERNAL_URL=http://api_server:8080 + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + + + relational_db: + image: postgres:15.2-alpine + command: -c 'max_connections=250' + restart: always + # POSTGRES_USER and POSTGRES_PASSWORD should be set in .env file + env_file: + - .env + volumes: + - db_volume:/var/lib/postgresql/data + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + + + inference_model_server: + image: danswer/danswer-model-server:${IMAGE_TAG:-latest} + build: + context: ../../backend + dockerfile: Dockerfile.model_server + command: > + /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then + echo 'Skipping service...'; + exit 0; + else + exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; + fi" + restart: on-failure + environment: + - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} + # Set to debug to get more fine-grained logs + - LOG_LEVEL=${LOG_LEVEL:-info} + volumes: + # Not necessary, this is just to reduce download time during startup + - model_cache_huggingface:/root/.cache/huggingface/ + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + + + indexing_model_server: + image: danswer/danswer-model-server:${IMAGE_TAG:-latest} + build: + context: ../../backend + dockerfile: Dockerfile.model_server + command: > + /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then + echo 'Skipping service...'; + exit 0; + else + exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; + fi" + restart: on-failure + environment: + - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} + - INDEXING_ONLY=True + # Set to debug to get more fine-grained logs + - LOG_LEVEL=${LOG_LEVEL:-info} + - VESPA_SEARCHER_THREADS=${VESPA_SEARCHER_THREADS:-1} + volumes: + # Not necessary, this is just to reduce download time during startup + - indexing_huggingface_model_cache:/root/.cache/huggingface/ + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + + + # This container name cannot have an underscore in it due to Vespa expectations of the URL + index: + image: vespaengine/vespa:8.277.17 + restart: always + ports: + - "19071:19071" + - "8081:8081" + volumes: + - vespa_volume:/opt/vespa/var + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + + + nginx: + image: nginx:1.23.4-alpine + restart: always + # nginx will immediately crash with `nginx: [emerg] host not found in upstream` + # if api_server / web_server are not up + depends_on: + - api_server + - web_server + ports: + - "80:80" + - "443:443" + volumes: + - ../data/nginx:/etc/nginx/conf.d + - ../data/certbot/conf:/etc/letsencrypt + - ../data/certbot/www:/var/www/certbot + # sleep a little bit to allow the web_server / api_server to start up. + # Without this we've seen issues where nginx shows no error logs but + # does not recieve any traffic + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + # The specified script waits for the api_server to start up. + # Without this we've seen issues where nginx shows no error logs but + # does not recieve any traffic + # NOTE: we have to use dos2unix to remove Carriage Return chars from the file + # in order to make this work on both Unix-like systems and windows + command: > + /bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh + && /etc/nginx/conf.d/run-nginx.sh app.conf.template" + env_file: + - .env.nginx + + + # follows https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71 + certbot: + image: certbot/certbot + restart: always + volumes: + - ../data/certbot/conf:/etc/letsencrypt + - ../data/certbot/www:/var/www/certbot + logging: + driver: json-file + options: + max-size: "50m" + max-file: "6" + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + + + cache: + image: redis:7.4-alpine + restart: always + ports: + - '6379:6379' + # docker silently mounts /data even without an explicit volume mount, which enables + # persistence. explicitly setting save and appendonly forces ephemeral behavior. + command: redis-server --save "" --appendonly no + + +volumes: + db_volume: + vespa_volume: + # Created by the container itself + model_cache_huggingface: + indexing_huggingface_model_cache: diff --git a/web/package-lock.json b/web/package-lock.json index 338cf0a9f0f..36a76cbe05c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7", + "@stripe/stripe-js": "^4.6.0", "@tremor/react": "^3.9.2", "@types/js-cookie": "^3.0.3", "@types/lodash": "^4.17.0", @@ -42,7 +43,7 @@ "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "semver": "^7.5.4", - "sharp": "^0.32.6", + "stripe": "^17.0.0", "swr": "^2.1.5", "tailwindcss": "^3.3.1", "typescript": "5.0.3", @@ -1670,6 +1671,14 @@ "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", "dev": true }, + "node_modules/@stripe/stripe-js": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-4.6.0.tgz", + "integrity": "sha512-ZoK0dMFnVH0J5XUWGqsta8S8xm980qEwJKAIgZcLQxaSsbGRB9CsVvfOjwQFE1JC1q3rPwb/b+gQAmzIESnHnA==", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -2415,11 +2424,6 @@ "dequal": "^2.0.3" } }, - "node_modules/b4a": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", - "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" - }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -2463,66 +2467,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "node_modules/bare-events": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.2.tgz", - "integrity": "sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==", - "optional": true - }, - "node_modules/bare-fs": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.0.tgz", - "integrity": "sha512-TNFqa1B4N99pds2a5NYHR15o0ZpdNKbAeKTE/+G6ED/UeOavv8RY3dr/Fu99HW3zU3pXpo2kDNO8Sjsm2esfOw==", - "optional": true, - "dependencies": { - "bare-events": "^2.0.0", - "bare-path": "^2.0.0", - "bare-stream": "^1.0.0" - } - }, - "node_modules/bare-os": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.3.0.tgz", - "integrity": "sha512-oPb8oMM1xZbhRQBngTgpcQ5gXw6kjOaRsSWsIeNyRxGed2w/ARyP7ScBYpWR1qfX2E5rS3gBw6OWcSQo+s+kUg==", - "optional": true - }, - "node_modules/bare-path": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.2.tgz", - "integrity": "sha512-o7KSt4prEphWUHa3QUwCxUI00R86VdjiuxmJK0iNVDHYPGo+HsDaVCnqCmPbf/MiW1ok8F4p3m8RTHlWk8K2ig==", - "optional": true, - "dependencies": { - "bare-os": "^2.1.0" - } - }, - "node_modules/bare-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-1.0.0.tgz", - "integrity": "sha512-KhNUoDL40iP4gFaLSsoGE479t0jHijfYdIcxRn/XtezA2BaUD0NRf/JGRpsMq6dMNM+SrCrB0YSSo/5wBY4rOQ==", - "optional": true, - "dependencies": { - "streamx": "^2.16.1" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2534,16 +2478,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2596,29 +2530,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2634,7 +2545,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -2787,11 +2697,6 @@ "node": ">= 6" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2805,18 +2710,6 @@ "node": ">=6" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2833,15 +2726,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3150,28 +3034,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3190,7 +3052,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -3228,14 +3089,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -3311,14 +3164,6 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.16.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.1.tgz", @@ -3420,7 +3265,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.4" }, @@ -3432,7 +3276,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -3959,14 +3802,6 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3986,11 +3821,6 @@ "node": ">=6.0.0" } }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" - }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4172,11 +4002,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4244,7 +4069,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -4296,11 +4120,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, "node_modules/glob": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", @@ -4410,7 +4229,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -4451,7 +4269,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -4463,7 +4280,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -4475,7 +4291,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -4694,25 +4509,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -4759,12 +4555,8 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/inline-style-parser": { "version": "0.2.3", @@ -4839,11 +4631,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -6312,17 +6099,6 @@ "node": ">=8.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6339,6 +6115,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6351,11 +6128,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -6388,11 +6160,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6475,22 +6242,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/node-abi": { - "version": "3.62.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.62.0.tgz", - "integrity": "sha512-CPMcGa+y33xuL1E0TcNIu4YyaZCxnnvkVaEXrsosR3FxN+fV8xvb7Mzpb7IgKler10qeMkE6+Dp8qJhpzdq35g==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" - }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -8926,7 +8677,6 @@ "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9042,6 +8792,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "dependencies": { "wrappy": "1" } @@ -9410,57 +9161,6 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, - "node_modules/prebuild-install": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", - "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/prebuild-install/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9517,15 +9217,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9535,6 +9226,20 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9554,33 +9259,6 @@ } ] }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -9828,19 +9506,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -10160,25 +9825,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/safe-regex-test": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", @@ -10219,7 +9865,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -10252,28 +9897,6 @@ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, - "node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", - "hasInstallScript": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10297,7 +9920,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", @@ -10322,57 +9944,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -10415,26 +9986,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.16.1.tgz", - "integrity": "sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==", - "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -10627,6 +10178,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.0.0.tgz", + "integrity": "sha512-URKpnjH2O+OWxhvXLIaEIaAkp2fQvqITm/3zJS0a3nGCREjH3qJYxmGowngA46Qu1x2MumNL3Y/OdY6uzIhpCQ==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/style-to-object": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.6.tgz", @@ -10842,29 +10405,6 @@ "node": ">=6" } }, - "node_modules/tar-fs": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", - "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^2.1.1", - "bare-path": "^2.1.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10993,17 +10533,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11611,7 +11140,8 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true }, "node_modules/yallist": { "version": "3.1.1", diff --git a/web/package.json b/web/package.json index 1e55fec590e..39d4b316e1e 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7", + "@stripe/stripe-js": "^4.6.0", "@tremor/react": "^3.9.2", "@types/js-cookie": "^3.0.3", "@types/lodash": "^4.17.0", @@ -43,7 +44,7 @@ "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "semver": "^7.5.4", - "sharp": "^0.32.6", + "stripe": "^17.0.0", "swr": "^2.1.5", "tailwindcss": "^3.3.1", "typescript": "5.0.3", @@ -56,4 +57,4 @@ "eslint-config-next": "^14.1.0", "prettier": "2.8.8" } -} \ No newline at end of file +} diff --git a/web/src/app/admin/settings/interfaces.ts b/web/src/app/admin/settings/interfaces.ts index 8327d69d448..2df8b5c26b2 100644 --- a/web/src/app/admin/settings/interfaces.ts +++ b/web/src/app/admin/settings/interfaces.ts @@ -1,3 +1,9 @@ +export enum GatingType { + FULL = "full", + PARTIAL = "partial", + NONE = "none", +} + export interface Settings { chat_page_enabled: boolean; search_page_enabled: boolean; @@ -6,6 +12,7 @@ export interface Settings { notifications: Notification[]; needs_reindexing: boolean; gpu_enabled: boolean; + product_gating: GatingType; } export interface Notification { diff --git a/web/src/app/ee/admin/cloud-settings/BillingInformationPage.tsx b/web/src/app/ee/admin/cloud-settings/BillingInformationPage.tsx new file mode 100644 index 00000000000..de2e4142947 --- /dev/null +++ b/web/src/app/ee/admin/cloud-settings/BillingInformationPage.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { CreditCard, ArrowFatUp } from "@phosphor-icons/react"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { loadStripe } from "@stripe/stripe-js"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { SettingsIcon } from "@/components/icons/icons"; +import { + updateSubscriptionQuantity, + fetchCustomerPortal, + statusToDisplay, + useBillingInformation, +} from "./utils"; +import { useEffect } from "react"; + +export default function BillingInformationPage() { + const router = useRouter(); + const { popup, setPopup } = usePopup(); + const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY! + ); + + const { + data: billingInformation, + error, + isLoading, + refreshBillingInformation, + } = useBillingInformation(); + + const [seats, setSeats] = useState(1); + + useEffect(() => { + if (billingInformation?.seats) { + setSeats(billingInformation.seats); + } + }, [billingInformation?.seats]); + + if (error) { + console.error("Failed to fetch billing information:", error); + } + useEffect(() => { + const url = new URL(window.location.href); + if (url.searchParams.has("session_id")) { + setPopup({ + message: + "Congratulations! Your subscription has been updated successfully.", + type: "success", + }); + // Remove the session_id from the URL + url.searchParams.delete("session_id"); + window.history.replaceState({}, "", url.toString()); + // You might want to refresh the billing information here + // by calling an API endpoint to get the latest data + } + }, [setPopup]); + + if (isLoading) { + return
Loading...
; + } + + const handleManageSubscription = async () => { + try { + const response = await fetchCustomerPortal(); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + `Failed to create customer portal session: ${errorData.message || response.statusText}` + ); + } + + const { url } = await response.json(); + + if (!url) { + throw new Error("No portal URL returned from the server"); + } + + router.push(url); + } catch (error) { + console.error("Error creating customer portal session:", error); + setPopup({ + message: "Error creating customer portal session", + type: "error", + }); + } + }; + if (!billingInformation) { + return
Loading...
; + } + + return ( +
+
+ {popup} + +

+ + Billing Information +

+ +
+
+
+
+

Seats

+

+ Number of licensed users +

+
+

+ {billingInformation.seats} +

+
+
+ +
+
+
+

+ Subscription Status +

+

+ Current state of your subscription +

+
+

+ {statusToDisplay(billingInformation.subscription_status)} +

+
+
+ +
+
+
+

+ Billing Start +

+

+ Start date of current billing cycle +

+
+

+ {new Date( + billingInformation.billing_start + ).toLocaleDateString()} +

+
+
+ +
+
+
+

Billing End

+

+ End date of current billing cycle +

+
+

+ {new Date(billingInformation.billing_end).toLocaleDateString()} +

+
+
+
+ + {!billingInformation.payment_method_enabled && ( +
+

Notice:

+

+ You'll need to add a payment method before your trial ends to + continue using the service. +

+
+ )} + + {billingInformation.subscription_status === "trialing" ? ( +
+

+ No cap on users during trial +

+
+ ) : ( +
+
+

+ Current Seats: +

+

+ {billingInformation.seats} +

+
+

+ Seats automatically update based on adding, removing, or inviting + users. +

+
+ )} +
+ +
+
+
+

+ Manage Subscription +

+

+ View your plan, update payment, or change subscription +

+
+ +
+ +
+
+ ); +} diff --git a/web/src/app/ee/admin/cloud-settings/page.tsx b/web/src/app/ee/admin/cloud-settings/page.tsx new file mode 100644 index 00000000000..6566e069ba7 --- /dev/null +++ b/web/src/app/ee/admin/cloud-settings/page.tsx @@ -0,0 +1,23 @@ +import { AdminPageTitle } from "@/components/admin/Title"; +import BillingInformationPage from "./BillingInformationPage"; +import { FaCloud } from "react-icons/fa"; + +export interface BillingInformation { + seats: number; + subscription_status: string; + billing_start: Date; + billing_end: Date; + payment_method_enabled: boolean; +} + +export default function page() { + return ( +
+ } + /> + +
+ ); +} diff --git a/web/src/app/ee/admin/cloud-settings/utils.ts b/web/src/app/ee/admin/cloud-settings/utils.ts new file mode 100644 index 00000000000..1f2aaa8e8eb --- /dev/null +++ b/web/src/app/ee/admin/cloud-settings/utils.ts @@ -0,0 +1,46 @@ +import { BillingInformation } from "./page"; +import useSWR, { mutate } from "swr"; + +export const updateSubscriptionQuantity = async (seats: number) => { + return await fetch("/api/tenants/update-subscription-quantity", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ quantity: seats }), + }); +}; + +export const fetchCustomerPortal = async () => { + return await fetch("/api/tenants/create-customer-portal-session", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); +}; + +export const statusToDisplay = (status: string) => { + switch (status) { + case "trialing": + return "Trialing"; + case "active": + return "Active"; + case "canceled": + return "Canceled"; + default: + return "Unknown"; + } +}; + +export const useBillingInformation = () => { + const url = "/api/tenants/billing-information"; + const swrResponse = useSWR(url, (url: string) => + fetch(url).then((res) => res.json()) + ); + + return { + ...swrResponse, + refreshBillingInformation: () => mutate(url), + }; +}; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index f49864aac75..17e01755fe1 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -13,14 +13,12 @@ import { Metadata } from "next"; import { buildClientUrl } from "@/lib/utilsSS"; import { Inter } from "next/font/google"; import Head from "next/head"; -import { EnterpriseSettings } from "./admin/settings/interfaces"; +import { EnterpriseSettings, GatingType } from "./admin/settings/interfaces"; import { Card } from "@tremor/react"; import { HeaderTitle } from "@/components/header/HeaderTitle"; import { Logo } from "@/components/Logo"; import { UserProvider } from "@/components/user/UserProvider"; import { ProviderContextProvider } from "@/components/chat_search/ProviderContext"; -import { redirect } from "next/navigation"; -import { headers } from "next/headers"; const inter = Inter({ subsets: ["latin"], @@ -57,6 +55,8 @@ export default async function RootLayout({ }) { const combinedSettings = await fetchSettingsSS(); + const productGating = GatingType.PARTIAL; + if (!combinedSettings) { return ( @@ -109,6 +109,41 @@ export default async function RootLayout({ ); } + if (productGating === GatingType.FULL) { + return ( + + + Access Restricted | Danswer + + +
+
+ Danswer + +
+ +

+ Access Restricted +

+

+ We regret to inform you that your access to Danswer has been + temporarily suspended due to a lapse in your subscription. +

+

+ To reinstate your access and continue benefiting from Danswer's + powerful features, please update your payment information. +

+

+ If you're an admin, you can resolve this by visiting the billing + section. For other users, please reach out to your administrator + to address this matter. +

+
+
+ + + ); + } return ( @@ -137,6 +172,20 @@ export default async function RootLayout({ process.env.THEME_IS_DARK?.toLowerCase() === "true" ? "dark" : "" }`} > + {productGating === GatingType.PARTIAL && ( +
+

+ Your account is pending payment!{" "} + + Update your billing information + {" "} + or access will be suspended soon. +

+
+ )} diff --git a/web/src/components/admin/ClientLayout.tsx b/web/src/components/admin/ClientLayout.tsx index e0415f84544..68154c90387 100644 --- a/web/src/components/admin/ClientLayout.tsx +++ b/web/src/components/admin/ClientLayout.tsx @@ -30,15 +30,18 @@ import { User } from "@/lib/types"; import { usePathname } from "next/navigation"; import { SettingsContext } from "../settings/SettingsProvider"; import { useContext } from "react"; +import { Cloud } from "@phosphor-icons/react"; export function ClientLayout({ user, children, enableEnterprise, + enableCloud, }: { user: User | null; children: React.ReactNode; enableEnterprise: boolean; + enableCloud: boolean; }) { const isCurator = user?.role === UserRole.CURATOR || user?.role === UserRole.GLOBAL_CURATOR; @@ -390,6 +393,22 @@ export function ClientLayout({ }, ] : []), + ...(enableCloud + ? [ + { + name: ( +
+ +
Cloud Settings
+
+ ), + link: "/admin/cloud-settings", + }, + ] + : []), ], }, ] diff --git a/web/src/components/admin/Layout.tsx b/web/src/components/admin/Layout.tsx index 145d8d34786..2a4fbaa3de9 100644 --- a/web/src/components/admin/Layout.tsx +++ b/web/src/components/admin/Layout.tsx @@ -6,7 +6,10 @@ import { } from "@/lib/userSS"; import { redirect } from "next/navigation"; import { ClientLayout } from "./ClientLayout"; -import { SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED } from "@/lib/constants"; +import { + SERVER_SIDE_ONLY__CLOUD_ENABLED, + SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED, +} from "@/lib/constants"; import { AnnouncementBanner } from "../header/AnnouncementBanner"; export async function Layout({ children }: { children: React.ReactNode }) { @@ -43,6 +46,7 @@ export async function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/web/src/components/admin/Title.tsx b/web/src/components/admin/Title.tsx index 9309cb3b997..0511a39dfed 100644 --- a/web/src/components/admin/Title.tsx +++ b/web/src/components/admin/Title.tsx @@ -1,3 +1,4 @@ +"use client"; import { HealthCheckBanner } from "../health/healthcheck"; import { Divider } from "@tremor/react"; diff --git a/web/src/components/settings/lib.ts b/web/src/components/settings/lib.ts index 1c1ec9249f1..ce263d3ff90 100644 --- a/web/src/components/settings/lib.ts +++ b/web/src/components/settings/lib.ts @@ -42,6 +42,7 @@ export async function fetchSettingsSS(): Promise { if (!results[0].ok) { if (results[0].status === 403 || results[0].status === 401) { settings = { + product_gated: false, gpu_enabled: false, chat_page_enabled: true, search_page_enabled: true, diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 4fe1d616dcd..3b209cfb364 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -56,6 +56,10 @@ export const CUSTOM_ANALYTICS_ENABLED = process.env.CUSTOM_ANALYTICS_SECRET_KEY export const DISABLE_LLM_DOC_RELEVANCE = process.env.DISABLE_LLM_DOC_RELEVANCE?.toLowerCase() === "true"; -export const CLOUD_ENABLED = process.env.NEXT_PUBLIC_CLOUD_ENABLED; +export const CLOUD_ENABLED = + process.env.NEXT_PUBLIC_CLOUD_ENABLED?.toLowerCase() === "true"; export const REGISTRATION_URL = process.env.INTERNAL_URL || "http://127.0.0.1:3001"; + +export const SERVER_SIDE_ONLY__CLOUD_ENABLED = true; +// process.env.NEXT_PUBLIC_CLOUD_ENABLED?.toLowerCase() === "true"; diff --git a/web/src/middleware.ts b/web/src/middleware.ts index b887bec8bbc..06c106fffc4 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -12,12 +12,16 @@ const eePaths = [ "/admin/whitelabeling/:path*", "/admin/performance/custom-analytics/:path*", "/admin/standard-answer/:path*", + ...(process.env.NEXT_PUBLIC_CLOUD_ENABLED + ? ["/admin/cloud-settings/:path*"] + : []), ]; // removes the "/:path*" from the end -const strippedEEPaths = eePaths.map((path) => - path.replace(/(.*):\path\*$/, "$1").replace(/\/$/, "") -); +const stripPath = (path: string) => + path.replace(/(.*):\path\*$/, "$1").replace(/\/$/, ""); + +const strippedEEPaths = eePaths.map(stripPath); export async function middleware(request: NextRequest) { if (SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {