From 642cc34e8ca6ea112b170ae065f5b889bdffe779 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 20 Feb 2026 18:21:24 +0530 Subject: [PATCH] feat: add authentication for OSS (#167) * feat: add authentication for OSS Fixes #157 and #156 * fix: fix token generation * fix: limit fastapi workers to 1 --- ...fd8fac02883_add_user_email_and_password.py | 34 +++++ api/constants.py | 5 + api/db/models.py | 2 + api/db/user_client.py | 33 +++++ api/requirements.txt | 2 + api/routes/auth.py | 97 ++++++++++++++ api/routes/main.py | 8 +- api/schemas/auth.py | 31 +++++ api/services/auth/depends.py | 73 ++++------- api/utils/auth.py | 28 +++++ api/utils/common.py | 4 - api/utils/tunnel.py | 1 - docker-compose.yaml | 8 +- scripts/setup_remote.sh | 5 + ui/.env.example | 4 - ui/Dockerfile | 4 +- ui/src/app/after-sign-in/page.tsx | 6 +- ui/src/app/api-keys/page.tsx | 8 +- ui/src/app/api/auth/logout/route.ts | 27 ++++ ui/src/app/api/auth/oss/route.ts | 48 ++----- ui/src/app/api/auth/session/route.ts | 33 +++++ ui/src/app/api/config/auth/route.ts | 8 ++ ui/src/app/api/config/version/route.ts | 22 ++++ ui/src/app/auth/login/page.tsx | 93 ++++++++++++++ ui/src/app/auth/signup/page.tsx | 118 ++++++++++++++++++ ui/src/app/handler/[...stack]/page.tsx | 14 ++- ui/src/app/integrations/page.tsx | 2 +- ui/src/app/looptalk/[id]/page.tsx | 2 +- ui/src/app/page.tsx | 4 +- ui/src/app/workflow/page.tsx | 2 +- ui/src/client/sdk.gen.ts | 40 +++++- ui/src/client/types.gen.ts | 116 +++++++++++++++++ ui/src/components/SignInClient.tsx | 21 ++-- ui/src/components/flow/nodes/StartCall.tsx | 29 ++--- ui/src/components/flow/nodes/TriggerNode.tsx | 3 +- ui/src/components/layout/AppLayout.tsx | 4 +- ui/src/components/layout/AppSidebar.tsx | 94 +++++++------- .../components/looptalk/LiveAudioPlayer.tsx | 6 +- ui/src/context/AppConfigContext.tsx | 13 ++ ui/src/context/UserConfigContext.tsx | 3 +- ui/src/lib/apiClient.ts | 12 +- ui/src/lib/auth/config.ts | 30 +++++ ui/src/lib/auth/providers/AuthProvider.tsx | 33 +++-- .../auth/providers/LocalProviderWrapper.tsx | 18 ++- ui/src/lib/auth/server.ts | 50 +++----- ui/src/lib/utils.ts | 6 - ui/src/middleware.ts | 72 ++++++----- ui/src/stack.tsx | 21 ---- 48 files changed, 994 insertions(+), 303 deletions(-) create mode 100644 api/alembic/versions/6fd8fac02883_add_user_email_and_password.py create mode 100644 api/routes/auth.py create mode 100644 api/schemas/auth.py create mode 100644 api/utils/auth.py create mode 100644 ui/src/app/api/auth/logout/route.ts create mode 100644 ui/src/app/api/auth/session/route.ts create mode 100644 ui/src/app/api/config/auth/route.ts create mode 100644 ui/src/app/auth/login/page.tsx create mode 100644 ui/src/app/auth/signup/page.tsx create mode 100644 ui/src/lib/auth/config.ts delete mode 100644 ui/src/stack.tsx diff --git a/api/alembic/versions/6fd8fac02883_add_user_email_and_password.py b/api/alembic/versions/6fd8fac02883_add_user_email_and_password.py new file mode 100644 index 0000000..8986547 --- /dev/null +++ b/api/alembic/versions/6fd8fac02883_add_user_email_and_password.py @@ -0,0 +1,34 @@ +"""add user email and password + +Revision ID: 6fd8fac02883 +Revises: 6d2f94baf4b7 +Create Date: 2026-02-20 11:43:47.679075 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "6fd8fac02883" +down_revision: Union[str, None] = "6d2f94baf4b7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("email", sa.String(), nullable=True)) + op.add_column("users", sa.Column("password_hash", sa.String(), nullable=True)) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_column("users", "password_hash") + op.drop_column("users", "email") + # ### end Alembic commands ### diff --git a/api/constants.py b/api/constants.py index 6f55e8d..e856116 100644 --- a/api/constants.py +++ b/api/constants.py @@ -25,6 +25,7 @@ DATABASE_URL = os.environ["DATABASE_URL"] REDIS_URL = os.environ["REDIS_URL"] DEPLOYMENT_MODE = os.getenv("DEPLOYMENT_MODE", "oss") +AUTH_PROVIDER = os.getenv("AUTH_PROVIDER", "local") DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None) MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com") @@ -118,3 +119,7 @@ TURN_HOST = os.getenv("TURN_HOST", "localhost") TURN_PORT = int(os.getenv("TURN_PORT", "3478")) TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349")) TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400")) + +# OSS Email/Password Auth +OSS_JWT_SECRET = os.getenv("OSS_JWT_SECRET", "change-me-in-production") +OSS_JWT_EXPIRY_HOURS = int(os.getenv("OSS_JWT_EXPIRY_HOURS", "720")) # 30 days diff --git a/api/db/models.py b/api/db/models.py index 409db0c..4a752d4 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -69,6 +69,8 @@ class UserModel(Base): back_populates="users", ) is_superuser = Column(Boolean, default=False) + email = Column(String, unique=True, index=True, nullable=True) + password_hash = Column(String, nullable=True) class UserConfigurationModel(Base): diff --git a/api/db/user_client.py b/api/db/user_client.py index ab57f50..6c98164 100644 --- a/api/db/user_client.py +++ b/api/db/user_client.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime, timezone from loguru import logger @@ -148,3 +149,35 @@ class UserClient(BaseDBClient): raise ValueError(f"User with ID {user_id} not found") await session.commit() + + async def update_user_email(self, user_id: int, email: str) -> None: + """Update the user's email address.""" + async with self.async_session() as session: + from sqlalchemy import update + + stmt = update(UserModel).where(UserModel.id == user_id).values(email=email) + await session.execute(stmt) + await session.commit() + + async def get_user_by_email(self, email: str) -> UserModel | None: + """Fetch a user by their email address.""" + async with self.async_session() as session: + result = await session.execute( + select(UserModel).where(UserModel.email == email) + ) + return result.scalars().first() + + async def create_user_with_email( + self, email: str, password_hash: str, name: str | None = None + ) -> UserModel: + """Create a new user with email and password hash.""" + async with self.async_session() as session: + user = UserModel( + provider_id=f"oss_{int(datetime.now(timezone.utc).timestamp())}_{uuid.uuid4()}", + email=email, + password_hash=password_hash, + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user diff --git a/api/requirements.txt b/api/requirements.txt index 2aca2b6..8852613 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -15,3 +15,5 @@ sqlalchemy[asyncio]==2.0.43 msgpack==1.1.2 docling[rapidocr]==2.68.0 pgvector==0.4.2 +bcrypt==5.0.0 +email-validator==2.3.0 diff --git a/api/routes/auth.py b/api/routes/auth.py new file mode 100644 index 0000000..9f22ee5 --- /dev/null +++ b/api/routes/auth.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException +from loguru import logger + +from api.db import db_client +from api.db.models import UserModel +from api.schemas.auth import AuthResponse, LoginRequest, SignupRequest, UserResponse +from api.services.auth.depends import create_user_configuration_with_mps_key, get_user +from api.utils.auth import create_jwt_token, hash_password, verify_password + +router = APIRouter( + prefix="/auth", + tags=["auth"], +) + + +@router.post("/signup", response_model=AuthResponse) +async def signup(request: SignupRequest): + # Check if email is already taken + existing_user = await db_client.get_user_by_email(request.email) + if existing_user: + raise HTTPException(status_code=409, detail="Email already registered") + + # Hash password and create user + hashed = hash_password(request.password) + user = await db_client.create_user_with_email( + email=request.email, + password_hash=hashed, + name=request.name, + ) + + # Create organization for the user + org_provider_id = f"org_{user.provider_id}" + organization, _ = await db_client.get_or_create_organization_by_provider_id( + org_provider_id=org_provider_id, user_id=user.id + ) + + # Link user to organization + await db_client.add_user_to_organization(user.id, organization.id) + await db_client.update_user_selected_organization(user.id, organization.id) + + # Create default service configuration + try: + mps_config = await create_user_configuration_with_mps_key( + user.id, organization.id, user.provider_id + ) + if mps_config: + await db_client.update_user_configuration(user.id, mps_config) + except Exception: + logger.warning( + "Failed to create default configuration for OSS user", exc_info=True + ) + + # Create JWT token + token = create_jwt_token(user.id, request.email) + + return AuthResponse( + token=token, + user=UserResponse( + id=user.id, + email=user.email, + name=request.name, + organization_id=organization.id, + ), + ) + + +@router.post("/login", response_model=AuthResponse) +async def login(request: LoginRequest): + # Look up user by email + user = await db_client.get_user_by_email(request.email) + if not user or not user.password_hash: + raise HTTPException(status_code=401, detail="Invalid email or password") + + # Verify password + if not verify_password(request.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid email or password") + + # Create JWT token + token = create_jwt_token(user.id, user.email) + + return AuthResponse( + token=token, + user=UserResponse( + id=user.id, + email=user.email, + organization_id=user.selected_organization_id, + ), + ) + + +@router.get("/me", response_model=UserResponse) +async def get_current_user(user: UserModel = Depends(get_user)): + return UserResponse( + id=user.id, + email=user.email, + organization_id=user.selected_organization_id, + ) diff --git a/api/routes/main.py b/api/routes/main.py index a53e8aa..cb86a71 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from loguru import logger from pydantic import BaseModel +from api.routes.auth import router as auth_router from api.routes.campaign import router as campaign_router from api.routes.credentials import router as credentials_router from api.routes.integration import router as integration_router @@ -50,17 +51,20 @@ router.include_router(public_agent_router) router.include_router(public_download_router) router.include_router(workflow_embed_router) router.include_router(knowledge_base_router) +router.include_router(auth_router) class HealthResponse(BaseModel): status: str version: str backend_api_endpoint: str + deployment_mode: str + auth_provider: str @router.get("/health", response_model=HealthResponse) async def health() -> HealthResponse: - from api.constants import APP_VERSION + from api.constants import APP_VERSION, AUTH_PROVIDER, DEPLOYMENT_MODE from api.utils.common import get_backend_endpoints logger.debug("Health endpoint called") @@ -69,4 +73,6 @@ async def health() -> HealthResponse: status="ok", version=APP_VERSION, backend_api_endpoint=backend_endpoint, + deployment_mode=DEPLOYMENT_MODE, + auth_provider=AUTH_PROVIDER, ) diff --git a/api/schemas/auth.py b/api/schemas/auth.py new file mode 100644 index 0000000..467940f --- /dev/null +++ b/api/schemas/auth.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, EmailStr, field_validator + + +class SignupRequest(BaseModel): + email: EmailStr + password: str + name: str | None = None + + @field_validator("password") + @classmethod + def password_min_length(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters") + return v + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: int + email: str | None + name: str | None = None + organization_id: int | None = None + + +class AuthResponse(BaseModel): + token: str + user: UserResponse diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index 79a3fcd..1470e27 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -5,12 +5,13 @@ from fastapi import Header, HTTPException, Query, WebSocket from loguru import logger from pydantic import ValidationError -from api.constants import DEPLOYMENT_MODE, DOGRAH_MPS_SECRET_KEY, MPS_API_URL +from api.constants import AUTH_PROVIDER, DOGRAH_MPS_SECRET_KEY, MPS_API_URL from api.db import db_client from api.db.models import UserModel from api.schemas.user_configuration import UserConfiguration from api.services.auth.stack_auth import stackauth from api.services.configuration.registry import ServiceProviders +from api.utils.auth import decode_jwt_token async def get_user( @@ -24,9 +25,9 @@ async def get_user( return await _handle_api_key_auth(x_api_key) # ------------------------------------------------------------------ - # Check if we're in OSS deployment mode + # Check if we're using local (email/password) auth # ------------------------------------------------------------------ - if DEPLOYMENT_MODE == "oss": + if AUTH_PROVIDER == "local": return await _handle_oss_auth(authorization) # ------------------------------------------------------------------ @@ -54,6 +55,14 @@ async def get_user( try: user_model = await db_client.get_or_create_user_by_provider_id(stack_user["id"]) + + # Sync email from Stack Auth if available and not already set + stack_email = stack_user.get("primary_email_verified") and stack_user.get( + "primary_email" + ) + if stack_email and user_model.email != stack_email: + await db_client.update_user_email(user_model.id, stack_email) + user_model.email = stack_email except Exception as e: raise HTTPException( status_code=500, detail=f"Error while creating user from database {e}" @@ -125,7 +134,7 @@ async def get_user_optional( async def _handle_oss_auth(authorization: str | None) -> UserModel: """ Handle authentication for OSS deployment mode. - Uses the authorization token as provider_id and creates user/org if needed. + Validates JWT tokens issued by the email/password auth flow. """ if not authorization: raise HTTPException(status_code=401, detail="Authorization header required") @@ -141,49 +150,15 @@ async def _handle_oss_auth(authorization: str | None) -> UserModel: raise HTTPException(status_code=401, detail="Invalid authorization token") try: - # Use token as provider_id for OSS mode - user_model = await db_client.get_or_create_user_by_provider_id( - provider_id=token - ) - - # Create or get organization for OSS user - # Each OSS user gets their own organization using their token as org ID - ( - organization, - org_was_created, - ) = await db_client.get_or_create_organization_by_provider_id( - org_provider_id=f"org_{token}", user_id=user_model.id - ) - - # Ensure user is mapped to their organization - if user_model.selected_organization_id != organization.id: - # add_user_to_organization now handles race conditions with ON CONFLICT DO NOTHING - await db_client.add_user_to_organization(user_model.id, organization.id) - await db_client.update_user_selected_organization( - user_model.id, organization.id - ) - user_model.selected_organization_id = organization.id - - # Only create default configuration if organization was just created - # This prevents race conditions where multiple concurrent requests - # might try to create configurations - if org_was_created: - existing_cfg = await db_client.get_user_configurations(user_model.id) - if not (existing_cfg.llm or existing_cfg.tts or existing_cfg.stt): - mps_config = await create_user_configuration_with_mps_key( - user_model.id, organization.id, token - ) - if mps_config: - await db_client.update_user_configuration( - user_model.id, mps_config - ) - - return user_model - - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Error while handling OSS authentication: {e}" - ) + payload = decode_jwt_token(token) + user = await db_client.get_user_by_id(int(payload["sub"])) + if user: + return user + raise HTTPException(status_code=401, detail="User not found") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=401, detail="Invalid or expired token") async def _handle_api_key_auth(api_key: str) -> UserModel: @@ -233,8 +208,8 @@ async def create_user_configuration_with_mps_key( async with httpx.AsyncClient() as client: # Use MPS API URL from constants - if DEPLOYMENT_MODE == "oss": - # For OSS mode, create a temporary service key without authentication + if AUTH_PROVIDER == "local": + # For local auth mode, create a temporary service key without authentication response = await client.post( f"{MPS_API_URL}/api/v1/service-keys/", json={ diff --git a/api/utils/auth.py b/api/utils/auth.py new file mode 100644 index 0000000..6a9298c --- /dev/null +++ b/api/utils/auth.py @@ -0,0 +1,28 @@ +from datetime import UTC, datetime, timedelta + +import bcrypt +import jwt + +from api.constants import OSS_JWT_EXPIRY_HOURS, OSS_JWT_SECRET + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + + +def create_jwt_token(user_id: int, email: str) -> str: + payload = { + "sub": str(user_id), + "email": email, + "exp": datetime.now(UTC) + timedelta(hours=OSS_JWT_EXPIRY_HOURS), + "iat": datetime.now(UTC), + } + return jwt.encode(payload, OSS_JWT_SECRET, algorithm="HS256") + + +def decode_jwt_token(token: str) -> dict: + return jwt.decode(token, OSS_JWT_SECRET, algorithms=["HS256"]) diff --git a/api/utils/common.py b/api/utils/common.py index b843d13..2770c5b 100644 --- a/api/utils/common.py +++ b/api/utils/common.py @@ -119,10 +119,6 @@ async def get_backend_endpoints() -> tuple[str, str]: _validate_url(BACKEND_API_ENDPOINT) if BACKEND_API_ENDPOINT: - logger.debug( - f"Processing BACKEND_API_ENDPOINT from environment: {BACKEND_API_ENDPOINT}" - ) - # Handle localhost/127.0.0.1 special case - use tunnel URL if available if "localhost" in BACKEND_API_ENDPOINT or "127.0.0.1" in BACKEND_API_ENDPOINT: logger.debug( diff --git a/api/utils/tunnel.py b/api/utils/tunnel.py index 8e2a521..930d7a2 100644 --- a/api/utils/tunnel.py +++ b/api/utils/tunnel.py @@ -27,7 +27,6 @@ class TunnelURLProvider: # Try to get URL from cloudflared metrics urls = await cls._get_cloudflared_urls() if urls: - logger.info(f"Retrieved tunnel URLs from cloudflared: {urls}") return urls except Exception as e: logger.warning(f"Failed to get tunnel URL from cloudflared: {e}") diff --git a/docker-compose.yaml b/docker-compose.yaml index 21402f7..e02384d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -103,6 +103,9 @@ services: MINIO_BUCKET: "voice-audio" MINIO_SECURE: "false" + # FastAPI workers count + FASTAPI_WORKERS: 1 + # Langfuse ENABLE_TRACING: "false" # LANGFUSE_SECRET_KEY: "" @@ -118,6 +121,8 @@ services: TURN_HOST: "${TURN_HOST:-}" TURN_SECRET: "${TURN_SECRET:-}" + OSS_JWT_SECRET: "${OSS_JWT_SECRET:-ChangeMeInProduction}" + ports: - "8000:8000" depends_on: @@ -148,7 +153,6 @@ services: # Server-side URL (SSR, internal Docker network) BACKEND_URL: "http://api:8000" NODE_ENV: "oss" - # Flag to enable/ disable posthog and sentry ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}" @@ -214,4 +218,4 @@ volumes: networks: app-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index d1e71c5..a734fc6 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -167,6 +167,8 @@ TURN_EOF echo -e "${GREEN}✓ turnserver.conf created${NC}" echo -e "${BLUE}[6/6] Creating environment file...${NC}" +OSS_JWT_SECRET=$(openssl rand -hex 32) + cat > .env << ENV_EOF # Backend API endpoint (for remote deployment) BACKEND_API_ENDPOINT=https://$SERVER_IP @@ -175,6 +177,9 @@ BACKEND_API_ENDPOINT=https://$SERVER_IP TURN_HOST=$SERVER_IP TURN_SECRET=$TURN_SECRET +# JWT secret for OSS authentication +OSS_JWT_SECRET=$OSS_JWT_SECRET + # Telemetry (set to false to disable) ENABLE_TELEMETRY=true ENV_EOF diff --git a/ui/.env.example b/ui/.env.example index 284108b..e6e901e 100644 --- a/ui/.env.example +++ b/ui/.env.example @@ -1,6 +1,2 @@ -NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 BACKEND_URL=http://localhost:8000 - NEXT_PUBLIC_NODE_ENV=development -NEXT_PUBLIC_DEPLOYMENT_MODE: "oss" -NEXT_PUBLIC_AUTH_PROVIDER="local" diff --git a/ui/Dockerfile b/ui/Dockerfile index 91acdb5..314cea7 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -37,12 +37,10 @@ COPY ui/src ./src # Set build-time environment variables (needed for Next.js build) ENV NEXT_PUBLIC_NODE_ENV="oss" -ENV NEXT_PUBLIC_AUTH_PROVIDER="local" -ENV NEXT_PUBLIC_DEPLOYMENT_MODE="oss" -ENV BACKEND_URL="http://api:8000" ENV NEXT_TELEMETRY_DISABLED="1" ENV NEXT_PUBLIC_CHATWOOT_URL="https://chat.dograh.com" ENV NEXT_PUBLIC_CHATWOOT_TOKEN="3fkFx2mCEjNHjM9gaNc4A82X" +ENV BACKEND_URL="http://api:8000" # Build the application with standalone mode # Increase Node.js heap size to prevent out-of-memory errors during build diff --git a/ui/src/app/after-sign-in/page.tsx b/ui/src/app/after-sign-in/page.tsx index 39796e9..90a886c 100644 --- a/ui/src/app/after-sign-in/page.tsx +++ b/ui/src/app/after-sign-in/page.tsx @@ -1,3 +1,4 @@ +import { isNextRouterError } from "next/dist/client/components/is-next-router-error"; import { redirect } from "next/navigation"; import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen"; @@ -9,7 +10,7 @@ export const dynamic = 'force-dynamic'; export default async function AfterSignInPage() { logger.debug('[AfterSignInPage] Starting after-sign-in page'); - const authProvider = getServerAuthProvider(); + const authProvider = await getServerAuthProvider(); logger.debug('[AfterSignInPage] Auth provider:', authProvider); logger.debug('[AfterSignInPage] Getting server user...'); const user = await getServerUser(); @@ -54,6 +55,9 @@ export default async function AfterSignInPage() { } } } catch (error) { + if (isNextRouterError(error)) { + throw error; + } logger.error('[AfterSignInPage] Error checking workflows:', error); } diff --git a/ui/src/app/api-keys/page.tsx b/ui/src/app/api-keys/page.tsx index 61aefaf..2558889 100644 --- a/ui/src/app/api-keys/page.tsx +++ b/ui/src/app/api-keys/page.tsx @@ -20,12 +20,14 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; +import { useAppConfig } from '@/context/AppConfigContext'; import { useAuth } from '@/lib/auth'; import logger from '@/lib/logger'; -import { isOSSMode } from '@/lib/utils'; export default function APIKeysPage() { const { user, getAccessToken, redirectToLogin, loading } = useAuth(); + const { config } = useAppConfig(); + const isOSS = config?.deploymentMode === 'oss'; logger.debug('[APIKeysPage] Component render', { loading, @@ -313,8 +315,8 @@ export default function APIKeysPage() { // In OSS mode, check if there's already an active service key const activeServiceKeys = serviceKeys.filter(key => !key.archived_at); - const canCreateServiceKey = !isOSSMode() || activeServiceKeys.length === 0; - const showServiceKeyArchiveControls = !isOSSMode(); + const canCreateServiceKey = !isOSS || activeServiceKeys.length === 0; + const showServiceKeyArchiveControls = !isOSS; return (
diff --git a/ui/src/app/api/auth/logout/route.ts b/ui/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..be7267f --- /dev/null +++ b/ui/src/app/api/auth/logout/route.ts @@ -0,0 +1,27 @@ +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +const OSS_TOKEN_COOKIE = 'dograh_auth_token'; +const OSS_USER_COOKIE = 'dograh_auth_user'; + +export async function POST() { + const cookieStore = await cookies(); + + cookieStore.set(OSS_TOKEN_COOKIE, '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 0, + path: '/', + }); + + cookieStore.set(OSS_USER_COOKIE, '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 0, + path: '/', + }); + + return NextResponse.json({ success: true }); +} diff --git a/ui/src/app/api/auth/oss/route.ts b/ui/src/app/api/auth/oss/route.ts index 6679fb4..6436b6c 100644 --- a/ui/src/app/api/auth/oss/route.ts +++ b/ui/src/app/api/auth/oss/route.ts @@ -1,19 +1,18 @@ /* Provides authentication token to LocalProviderWrapper once loaded - in the browser + in the browser. + Returns 401 if no token cookie exists (user needs to log in). */ import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; -const OSS_TOKEN_COOKIE = 'dograh_oss_token'; -const OSS_USER_COOKIE = 'dograh_oss_user'; +import { getAuthProvider } from '@/lib/auth/config'; -function generateOSSToken(): string { - return `oss_${Date.now()}_${crypto.randomUUID()}`; -} +const OSS_TOKEN_COOKIE = 'dograh_auth_token'; +const OSS_USER_COOKIE = 'dograh_auth_user'; export async function GET() { - const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack'; + const authProvider = await getAuthProvider(); // Only handle OSS mode if (authProvider !== 'local') { @@ -21,40 +20,17 @@ export async function GET() { } const cookieStore = await cookies(); - let token = cookieStore.get(OSS_TOKEN_COOKIE)?.value; - let user = cookieStore.get(OSS_USER_COOKIE)?.value; + const token = cookieStore.get(OSS_TOKEN_COOKIE)?.value; + const user = cookieStore.get(OSS_USER_COOKIE)?.value; - // If no token exists, create one + // If no token exists, return 401 (user needs to sign up or log in) if (!token) { - token = generateOSSToken(); - user = JSON.stringify({ - id: token, - name: 'Local User', - provider: 'local', - organizationId: `org_${token}`, - }); - - // Set cookies - cookieStore.set(OSS_TOKEN_COOKIE, token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30, // 30 days - path: '/', - }); - - cookieStore.set(OSS_USER_COOKIE, user, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30, // 30 days - path: '/', - }); + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - // Return the auth info as JSON (safe to expose to client) + // Return the auth info as JSON return NextResponse.json({ token, - user: JSON.parse(user!), + user: user ? JSON.parse(user) : { id: token, name: 'Local User', provider: 'local' }, }); } diff --git a/ui/src/app/api/auth/session/route.ts b/ui/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..fbc35ca --- /dev/null +++ b/ui/src/app/api/auth/session/route.ts @@ -0,0 +1,33 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +const OSS_TOKEN_COOKIE = 'dograh_auth_token'; +const OSS_USER_COOKIE = 'dograh_auth_user'; + +export async function POST(request: NextRequest) { + const { token, user } = await request.json(); + + if (!token) { + return NextResponse.json({ error: 'Missing token' }, { status: 400 }); + } + + const cookieStore = await cookies(); + + cookieStore.set(OSS_TOKEN_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + path: '/', + }); + + cookieStore.set(OSS_USER_COOKIE, JSON.stringify(user), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + path: '/', + }); + + return NextResponse.json({ success: true }); +} diff --git a/ui/src/app/api/config/auth/route.ts b/ui/src/app/api/config/auth/route.ts new file mode 100644 index 0000000..f0a8d96 --- /dev/null +++ b/ui/src/app/api/config/auth/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +import { getAuthProvider } from '@/lib/auth/config'; + +export async function GET() { + const provider = await getAuthProvider(); + return NextResponse.json({ provider }); +} diff --git a/ui/src/app/api/config/version/route.ts b/ui/src/app/api/config/version/route.ts index 241085f..a039360 100644 --- a/ui/src/app/api/config/version/route.ts +++ b/ui/src/app/api/config/version/route.ts @@ -6,28 +6,50 @@ import type { HealthResponse } from "@/client/types.gen"; // Import version from package.json at build time import packageJson from "../../../../../package.json"; +// Internal/local URLs that are not reachable from the browser +const INTERNAL_HOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1|api)(:\d+)?(\/|$)/; + +function isInternalUrl(url: string | undefined | null): boolean { + return !url || INTERNAL_HOST_RE.test(url); +} + export async function GET() { const uiVersion = packageJson.version || "dev"; // Fetch backend version and config from health endpoint let apiVersion = "unknown"; let backendApiEndpoint: string | null = null; + let deploymentMode = "oss"; + let authProvider = "local"; try { const response = await healthApiV1HealthGet(); if (response.data) { const data = response.data as HealthResponse; apiVersion = data.version; + // Pass through the backend's own endpoint for display purposes backendApiEndpoint = data.backend_api_endpoint; + deploymentMode = data.deployment_mode; + authProvider = data.auth_provider; } } catch { // Backend might not be reachable during build or in some deployments apiVersion = "unavailable"; } + // For the API client base URL: prefer BACKEND_URL env, fall back to + // health endpoint value. Skip internal/Docker-only URLs (e.g. http://api:8000) + // that aren't reachable from the browser — the client will keep using + // window.location.origin via the Next.js proxy instead. + const clientCandidate = process.env.BACKEND_URL || backendApiEndpoint; + const clientApiBaseUrl = isInternalUrl(clientCandidate) ? 'http://localhost:8000' : clientCandidate; + return NextResponse.json({ ui: uiVersion, api: apiVersion, backendApiEndpoint, + clientApiBaseUrl, + deploymentMode, + authProvider, }); } diff --git a/ui/src/app/auth/login/page.tsx b/ui/src/app/auth/login/page.tsx new file mode 100644 index 0000000..39c6ceb --- /dev/null +++ b/ui/src/app/auth/login/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { loginApiV1AuthLoginPost } from "@/client/sdk.gen"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + const res = await loginApiV1AuthLoginPost({ + body: { email, password }, + }); + + if (res.error || !res.data) { + const detail = (res.error as { detail?: string })?.detail; + toast.error(detail || "Login failed"); + return; + } + + // Set httpOnly cookies via server route + await fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: res.data.token, user: res.data.user }), + }); + + window.location.href = "/after-sign-in"; + } catch { + toast.error("An error occurred. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + Sign in + Enter your email and password to continue + + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+ ); +} diff --git a/ui/src/app/auth/signup/page.tsx b/ui/src/app/auth/signup/page.tsx new file mode 100644 index 0000000..d9d98c1 --- /dev/null +++ b/ui/src/app/auth/signup/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { signupApiV1AuthSignupPost } from "@/client/sdk.gen"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function SignupPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (password.length < 8) { + toast.error("Password must be at least 8 characters"); + return; + } + + if (password !== confirmPassword) { + toast.error("Passwords do not match"); + return; + } + + setLoading(true); + + try { + const res = await signupApiV1AuthSignupPost({ + body: { email, password }, + }); + + if (res.error || !res.data) { + const detail = (res.error as { detail?: string })?.detail; + toast.error(detail || "Signup failed"); + return; + } + + // Set httpOnly cookies via server route + await fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: res.data.token, user: res.data.user }), + }); + + window.location.href = "/after-sign-in"; + } catch { + toast.error("An error occurred. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + Create an account + Enter your details to get started + + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + /> +
+ +
+

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ); +} diff --git a/ui/src/app/handler/[...stack]/page.tsx b/ui/src/app/handler/[...stack]/page.tsx index 0c0a5fe..486d1fd 100644 --- a/ui/src/app/handler/[...stack]/page.tsx +++ b/ui/src/app/handler/[...stack]/page.tsx @@ -1,12 +1,11 @@ import { StackHandler } from "@stackframe/stack"; -import { stackServerApp } from "../../../stack"; +import { getAuthProvider } from "@/lib/auth/config"; -const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER; +export default async function Handler(props: unknown) { + const authProvider = await getAuthProvider(); -export default function Handler(props: unknown) { if (authProvider === "local") { - // Return a simple message when using local auth return (

Local Auth Mode

@@ -14,9 +13,14 @@ export default function Handler(props: unknown) {
); } + + // Lazily import the real StackServerApp only when needed + const { getStackServerApp } = await import("@/lib/auth/server"); + const app = await getStackServerApp(); + return ; } diff --git a/ui/src/app/integrations/page.tsx b/ui/src/app/integrations/page.tsx index 264b7fd..1277833 100644 --- a/ui/src/app/integrations/page.tsx +++ b/ui/src/app/integrations/page.tsx @@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic'; // Server component for integration list async function IntegrationList() { - const authProvider = getServerAuthProvider(); + const authProvider = await getServerAuthProvider(); const accessToken = await getServerAccessToken(); if (!accessToken) { diff --git a/ui/src/app/looptalk/[id]/page.tsx b/ui/src/app/looptalk/[id]/page.tsx index cd76eb4..8bbfff7 100644 --- a/ui/src/app/looptalk/[id]/page.tsx +++ b/ui/src/app/looptalk/[id]/page.tsx @@ -21,7 +21,7 @@ interface PageProps { } async function PageContent({ params }: PageProps) { - const authProvider = getServerAuthProvider(); + const authProvider = await getServerAuthProvider(); const accessToken = await getServerAccessToken(); if (!accessToken) { diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index 60a175f..e0f8107 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic'; export default async function Home() { logger.debug('[HomePage] Starting Home page render'); - const authProvider = getServerAuthProvider(); + const authProvider = await getServerAuthProvider(); logger.debug('[HomePage] Auth provider:', authProvider); // For local/OSS provider, check if user has workflows @@ -39,6 +39,8 @@ export default async function Home() { logger.debug('[HomePage] Redirecting to /workflow/create - no workflows found'); redirect('/workflow/create'); } + } else { + redirect('/auth/login'); } } catch (error) { // Re-throw navigation errors (redirects, not found, etc.) - they're intentional diff --git a/ui/src/app/workflow/page.tsx b/ui/src/app/workflow/page.tsx index 36dc683..a5f9353 100644 --- a/ui/src/app/workflow/page.tsx +++ b/ui/src/app/workflow/page.tsx @@ -13,7 +13,7 @@ export const dynamic = 'force-dynamic'; // Server component for workflow list async function WorkflowList() { - const authProvider = getServerAuthProvider(); + const authProvider = await getServerAuthProvider(); const accessToken = await getServerAccessToken(); if (!accessToken) { diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index 339e0bc..9fe7253 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client,Options as ClientOptions, TDataShape } from '@hey-api/client-fetch'; import { client as _heyApiClient } from './client.gen'; -import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteResponse, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetError, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetResponse, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetError, GetTurnCredentialsApiV1TurnCredentialsGetResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData, HealthApiV1HealthGetResponse,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, InitiateCallTransferApiV1TelephonyCallTransferPostData, InitiateCallTransferApiV1TelephonyCallTransferPostError, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UpdateCampaignApiV1CampaignCampaignIdPatchData, UpdateCampaignApiV1CampaignCampaignIdPatchError, UpdateCampaignApiV1CampaignCampaignIdPatchResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; +import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteResponse, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetCurrentUserApiV1AuthMeGetData, GetCurrentUserApiV1AuthMeGetError, GetCurrentUserApiV1AuthMeGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetError, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetResponse, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetError, GetTurnCredentialsApiV1TurnCredentialsGetResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData, HealthApiV1HealthGetResponse,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, InitiateCallTransferApiV1TelephonyCallTransferPostData, InitiateCallTransferApiV1TelephonyCallTransferPostError, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetResponse, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostError, LoginApiV1AuthLoginPostResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, SignupApiV1AuthSignupPostData, SignupApiV1AuthSignupPostError, SignupApiV1AuthSignupPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UpdateCampaignApiV1CampaignCampaignIdPatchData, UpdateCampaignApiV1CampaignCampaignIdPatchError, UpdateCampaignApiV1CampaignCampaignIdPatchResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; export type Options = ClientOptions & { /** @@ -1628,6 +1628,44 @@ export const searchChunksApiV1KnowledgeBaseSearchPost = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/api/v1/auth/signup', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Login + */ +export const loginApiV1AuthLoginPost = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/api/v1/auth/login', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + +/** + * Get Current User + */ +export const getCurrentUserApiV1AuthMeGet = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/api/v1/auth/me', + ...options + }); +}; + /** * Health */ diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index 2ecfad1..4256804 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -80,6 +80,11 @@ export type AdminCommentResponse = { admin_comment_ts: string; }; +export type AuthResponse = { + token: string; + user: UserResponse; +}; + export type AuthUserResponse = { id: number; is_superuser: boolean; @@ -599,6 +604,8 @@ export type HealthResponse = { status: string; version: string; backend_api_endpoint: string; + deployment_mode: string; + auth_provider: string; }; /** @@ -721,6 +728,11 @@ export type LoadTestStatsResponse = { }>; }; +export type LoginRequest = { + email: string; + password: string; +}; + export type PresignedUploadUrlRequest = { /** * CSV filename @@ -808,6 +820,12 @@ export type SessionResponse = { expires_at: string; }; +export type SignupRequest = { + email: string; + password: string; + name?: string | null; +}; + export type SuperuserWorkflowRunResponse = { id: number; name: string; @@ -1129,6 +1147,13 @@ export type UserConfigurationRequestResponseSchema = { } | null; }; +export type UserResponse = { + id: number; + email: string | null; + name?: string | null; + organization_id?: number | null; +}; + export type ValidateWorkflowResponse = { is_valid: boolean; errors: Array; @@ -5018,6 +5043,97 @@ export type SearchChunksApiV1KnowledgeBaseSearchPostResponses = { export type SearchChunksApiV1KnowledgeBaseSearchPostResponse = SearchChunksApiV1KnowledgeBaseSearchPostResponses[keyof SearchChunksApiV1KnowledgeBaseSearchPostResponses]; +export type SignupApiV1AuthSignupPostData = { + body: SignupRequest; + path?: never; + query?: never; + url: '/api/v1/auth/signup'; +}; + +export type SignupApiV1AuthSignupPostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type SignupApiV1AuthSignupPostError = SignupApiV1AuthSignupPostErrors[keyof SignupApiV1AuthSignupPostErrors]; + +export type SignupApiV1AuthSignupPostResponses = { + /** + * Successful Response + */ + 200: AuthResponse; +}; + +export type SignupApiV1AuthSignupPostResponse = SignupApiV1AuthSignupPostResponses[keyof SignupApiV1AuthSignupPostResponses]; + +export type LoginApiV1AuthLoginPostData = { + body: LoginRequest; + path?: never; + query?: never; + url: '/api/v1/auth/login'; +}; + +export type LoginApiV1AuthLoginPostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type LoginApiV1AuthLoginPostError = LoginApiV1AuthLoginPostErrors[keyof LoginApiV1AuthLoginPostErrors]; + +export type LoginApiV1AuthLoginPostResponses = { + /** + * Successful Response + */ + 200: AuthResponse; +}; + +export type LoginApiV1AuthLoginPostResponse = LoginApiV1AuthLoginPostResponses[keyof LoginApiV1AuthLoginPostResponses]; + +export type GetCurrentUserApiV1AuthMeGetData = { + body?: never; + headers?: { + authorization?: string | null; + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/auth/me'; +}; + +export type GetCurrentUserApiV1AuthMeGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetCurrentUserApiV1AuthMeGetError = GetCurrentUserApiV1AuthMeGetErrors[keyof GetCurrentUserApiV1AuthMeGetErrors]; + +export type GetCurrentUserApiV1AuthMeGetResponses = { + /** + * Successful Response + */ + 200: UserResponse; +}; + +export type GetCurrentUserApiV1AuthMeGetResponse = GetCurrentUserApiV1AuthMeGetResponses[keyof GetCurrentUserApiV1AuthMeGetResponses]; + export type HealthApiV1HealthGetData = { body?: never; path?: never; diff --git a/ui/src/components/SignInClient.tsx b/ui/src/components/SignInClient.tsx index 0ecf521..72d15cb 100644 --- a/ui/src/components/SignInClient.tsx +++ b/ui/src/components/SignInClient.tsx @@ -2,6 +2,10 @@ import { Loader2 } from 'lucide-react'; import dynamic from 'next/dynamic'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +import { useAuth } from '@/lib/auth'; import Footer from './Footer'; @@ -12,16 +16,19 @@ const SignIn = dynamic( ); export default function SignInClient() { - const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack'; + const { provider } = useAuth(); + const router = useRouter(); - if (authProvider !== 'stack') { + useEffect(() => { + if (provider === 'local') { + router.replace('/auth/login'); + } + }, [provider, router]); + + if (provider !== 'stack') { return (
-
-

Local Authentication

-

Local authentication is enabled. No sign-in required.

-
-
+
); } diff --git a/ui/src/components/flow/nodes/StartCall.tsx b/ui/src/components/flow/nodes/StartCall.tsx index abec38d..3370b37 100644 --- a/ui/src/components/flow/nodes/StartCall.tsx +++ b/ui/src/components/flow/nodes/StartCall.tsx @@ -14,7 +14,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { isOSSMode } from "@/lib/utils"; import { NodeContent } from "./common/NodeContent"; import { NodeEditDialog } from "./common/NodeEditDialog"; @@ -350,21 +349,19 @@ const StartCallEditForm = ({ Add Global Prompt
- {!isOSSMode() && ( -
- - - -
- )} +
+ + + +
{ const [triggerPath] = useState(() => data.trigger_path ?? crypto.randomUUID()); // Get backend URL from app config (fetched from backend health endpoint) - // Falls back to env variable, then to localhost for local development - const backendUrl = config?.backendApiEndpoint || process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000"; + const backendUrl = config?.backendApiEndpoint || "http://localhost:8000"; const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`; // Copy state for button feedback diff --git a/ui/src/components/layout/AppLayout.tsx b/ui/src/components/layout/AppLayout.tsx index efe61b4..d13605e 100644 --- a/ui/src/components/layout/AppLayout.tsx +++ b/ui/src/components/layout/AppLayout.tsx @@ -21,8 +21,8 @@ const AppLayout: React.FC = ({ const pathname = usePathname(); // Check if current route should have sidebar - // Hide sidebar for root (/) and /handler routes (Stack Auth routes) - const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler"); + // Hide sidebar for root (/), /handler routes (Stack Auth routes), and /auth routes + const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler") && !pathname.startsWith("/auth"); // Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname); diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx index ccad13d..8f0c476 100644 --- a/ui/src/components/layout/AppSidebar.tsx +++ b/ui/src/components/layout/AppSidebar.tsx @@ -8,7 +8,6 @@ import { CircleDollarSign, Database, FileText, - HelpCircle, Home, Key, LogOut, @@ -24,7 +23,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import React from "react"; +import React, { useRef } from "react"; import ThemeToggle from "@/components/ThemeSwitcher"; import { Button } from "@/components/ui/button"; @@ -57,6 +56,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useAppConfig } from "@/context/AppConfigContext"; +import type { LocalUser } from "@/lib/auth"; import { useAuth } from "@/lib/auth"; import { cn } from "@/lib/utils"; @@ -75,7 +75,14 @@ export function AppSidebar() { const { config } = useAppConfig(); // Get selected team for Stack auth (cast to Team type from Stack) - const selectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null; + // Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes, + // preventing unnecessary PATCH calls to Stack Auth on every route navigation. + const selectedTeamRef = useRef(null); + const rawSelectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null; + if (rawSelectedTeam?.id !== selectedTeamRef.current?.id) { + selectedTeamRef.current = rawSelectedTeam; + } + const selectedTeam = selectedTeamRef.current; // Version info from app config context const versionInfo = config ? { ui: config.uiVersion, api: config.apiVersion } : null; @@ -358,54 +365,45 @@ export function AppSidebar() { )}> {/* Bottom Actions */}
- {/* Get Help - for OSS mode */} + {/* User Button - for local/OSS mode */} {provider !== "stack" && ( - <> - {state === "collapsed" ? ( - - - - - - -

Get Help

-
-
-
- ) : ( - - )} - +
+ + + + + + +
+ {(user as LocalUser | undefined)?.email && ( +

{(user as LocalUser).email}

+ )} +
+
+ + logout()} className="cursor-pointer"> + + Sign out + +
+
+
)} - {/* User Button - at the bottom */} + {/* User Button - for Stack auth */} {provider === "stack" && (
(undefined); const isConnectingRef = useRef(false); + const { config } = useAppConfig(); const { user, getAccessToken } = useAuth(); // Auto-start streaming when session starts @@ -98,7 +100,7 @@ export function LiveAudioPlayer({ const accessToken = await getAccessToken(); // Create WebSocket connection - const baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL?.replace('http', 'ws') || 'ws://localhost:8000'; + const baseUrl = (config?.backendApiEndpoint || 'http://localhost:8000').replace(/^http/, 'ws'); const wsUrl = `${baseUrl}/api/v1/looptalk/test-sessions/${testSessionId}/audio-stream?role=${audioRole}&token=${encodeURIComponent(accessToken || '')}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; @@ -199,7 +201,7 @@ export function LiveAudioPlayer({ } finally { isConnectingRef.current = false; } - }, [testSessionId, audioRole, user, getAccessToken, volume, monitorAudioLevel]); // Removed connectionStatus to avoid loops + }, [testSessionId, audioRole, user, getAccessToken, volume, monitorAudioLevel, config]); // Removed connectionStatus to avoid loops const disconnect = useCallback(() => { if (wsRef.current) { diff --git a/ui/src/context/AppConfigContext.tsx b/ui/src/context/AppConfigContext.tsx index e55ef76..1e5160a 100644 --- a/ui/src/context/AppConfigContext.tsx +++ b/ui/src/context/AppConfigContext.tsx @@ -2,10 +2,14 @@ import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; +import { client } from '@/client/client.gen'; + interface AppConfig { uiVersion: string; apiVersion: string; backendApiEndpoint: string | null; + deploymentMode: string; + authProvider: string; } interface AppConfigContextType { @@ -17,6 +21,8 @@ const defaultConfig: AppConfig = { uiVersion: 'dev', apiVersion: 'unknown', backendApiEndpoint: null, + deploymentMode: 'oss', + authProvider: 'local', }; const AppConfigContext = createContext({ @@ -32,10 +38,17 @@ export function AppConfigProvider({ children }: { children: ReactNode }) { fetch('/api/config/version') .then((res) => res.json()) .then((data) => { + // Use clientApiBaseUrl (filtered for browser-reachable URLs) + // to configure the API client; keep backendApiEndpoint for display + if (data.clientApiBaseUrl) { + client.setConfig({ baseUrl: data.clientApiBaseUrl }); + } setConfig({ uiVersion: data.ui || 'dev', apiVersion: data.api || 'unknown', backendApiEndpoint: data.backendApiEndpoint || null, + deploymentMode: data.deploymentMode || 'oss', + authProvider: data.authProvider || 'local', }); }) .catch(() => { diff --git a/ui/src/context/UserConfigContext.tsx b/ui/src/context/UserConfigContext.tsx index 7ad7000..bf54c20 100644 --- a/ui/src/context/UserConfigContext.tsx +++ b/ui/src/context/UserConfigContext.tsx @@ -2,6 +2,7 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { client } from '@/client/client.gen'; import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen'; import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen'; import { setupAuthInterceptor } from '@/lib/apiClient'; @@ -71,7 +72,7 @@ export function UserConfigProvider({ children }: { children: ReactNode }) { // so it's in place before any child effects fire API calls. // setupAuthInterceptor is idempotent — safe for strict mode double-renders. if (!auth.loading && auth.isAuthenticated) { - setupAuthInterceptor(auth.getAccessToken); + setupAuthInterceptor(client, auth.getAccessToken); } // Fetch permissions once when auth is ready diff --git a/ui/src/lib/apiClient.ts b/ui/src/lib/apiClient.ts index 63474c3..f8cdc69 100644 --- a/ui/src/lib/apiClient.ts +++ b/ui/src/lib/apiClient.ts @@ -1,5 +1,6 @@ +import type { Client } from '@hey-api/client-fetch'; + import type { CreateClientConfig } from '@/client/client.gen'; -import { client } from '@/client/client.gen'; export const createClientConfig: CreateClientConfig = (config) => { // Use different URLs for server-side vs client-side @@ -10,8 +11,9 @@ export const createClientConfig: CreateClientConfig = (config) => { // for server-side rendering, still use environment variable as fallback baseUrl = process.env.BACKEND_URL || 'http://api:8000'; } else { - // for client-side, use the current browser URL's origin - baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin; + // Client-side API calls are proxied through Next.js rewrites. + // AppConfigContext may update this later with the fetched backend URL. + baseUrl = window.location.origin; } return { @@ -26,11 +28,11 @@ let interceptorRegistered = false; * Register a request interceptor that attaches a fresh access token * to every outgoing SDK request. Idempotent — safe for React strict mode. */ -export function setupAuthInterceptor(getAccessToken: () => Promise) { +export function setupAuthInterceptor(apiClient: Client, getAccessToken: () => Promise) { if (interceptorRegistered) return; interceptorRegistered = true; - client.interceptors.request.use(async (request) => { + apiClient.interceptors.request.use(async (request) => { if (request.headers.get('Authorization')) { return request; } diff --git a/ui/src/lib/auth/config.ts b/ui/src/lib/auth/config.ts new file mode 100644 index 0000000..b58927b --- /dev/null +++ b/ui/src/lib/auth/config.ts @@ -0,0 +1,30 @@ +import "server-only"; + +let cachedAuthProvider: string | null = null; + +/** + * Fetches the auth provider from the backend health endpoint and caches it. + * Falls back to 'local' on error. + */ +export async function getAuthProvider(): Promise { + if (cachedAuthProvider) { + return cachedAuthProvider; + } + + try { + const backendUrl = process.env.BACKEND_URL || "http://localhost:8000"; + const res = await fetch(`${backendUrl}/api/v1/health`, { + next: { revalidate: 300 }, + }); + if (res.ok) { + const data = await res.json(); + cachedAuthProvider = (data.auth_provider as string) || "local"; + return cachedAuthProvider; + } + } catch { + // Backend not reachable — fall back to local + } + + cachedAuthProvider = "local"; + return cachedAuthProvider; +} diff --git a/ui/src/lib/auth/providers/AuthProvider.tsx b/ui/src/lib/auth/providers/AuthProvider.tsx index 28877aa..3800fa9 100644 --- a/ui/src/lib/auth/providers/AuthProvider.tsx +++ b/ui/src/lib/auth/providers/AuthProvider.tsx @@ -1,7 +1,7 @@ 'use client'; import { Loader2 } from 'lucide-react'; -import React, { createContext, lazy, Suspense, useContext } from 'react'; +import React, { createContext, lazy, Suspense, useContext, useEffect, useState } from 'react'; import type { AuthUser } from '../types'; @@ -34,17 +34,30 @@ const LocalProviderWrapper = lazy(() => })) ); +const LoadingFallback = ( +
+ +
+); + export function AuthProvider({ children }: { children: React.ReactNode }) { - const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack'; + const [authProvider, setAuthProvider] = useState(null); + + useEffect(() => { + fetch('/api/config/auth') + .then((res) => res.json()) + .then((data) => setAuthProvider(data.provider || 'stack')) + .catch(() => setAuthProvider('local')); + }, []); + + if (!authProvider) { + return LoadingFallback; + } // For Stack provider, use the dedicated wrapper if (authProvider === 'stack') { return ( - - -
- }> + {children} @@ -54,11 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // For local/OSS provider return ( - - -
- }> + {children} diff --git a/ui/src/lib/auth/providers/LocalProviderWrapper.tsx b/ui/src/lib/auth/providers/LocalProviderWrapper.tsx index 1ce5b2c..e22dbfe 100644 --- a/ui/src/lib/auth/providers/LocalProviderWrapper.tsx +++ b/ui/src/lib/auth/providers/LocalProviderWrapper.tsx @@ -23,6 +23,12 @@ export function LocalProviderWrapper({ children }: { children: React.ReactNode } tokenRef.current = data.token; setUser(data.user); logger.info('OSS auth initialized', { user: data.user }); + } else if (response.status === 401) { + // No token - redirect to login (but not if already on auth pages) + if (!window.location.pathname.startsWith('/auth/')) { + window.location.href = '/auth/login'; + return; + } } else { logger.error('Failed to initialize OSS auth'); } @@ -48,17 +54,23 @@ export function LocalProviderWrapper({ children }: { children: React.ReactNode } }, []); const redirectToLogin = React.useCallback(() => { - logger.info('Login redirect not needed in local mode'); + window.location.href = '/auth/login'; }, []); const logout = React.useCallback(async () => { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } catch (error) { + logger.error('Error during logout', error); + } setUser(null); - logger.info('Logout requested in OSS mode - server cookies need to be cleared'); + tokenRef.current = null; + window.location.href = '/auth/login'; }, []); const contextValue = useMemo(() => ({ user: user as AuthUser, - isAuthenticated: !loading, + isAuthenticated: !!user, loading, getAccessToken, redirectToLogin, diff --git a/ui/src/lib/auth/server.ts b/ui/src/lib/auth/server.ts index a9255c6..89e883b 100644 --- a/ui/src/lib/auth/server.ts +++ b/ui/src/lib/auth/server.ts @@ -5,20 +5,21 @@ import { cookies } from 'next/headers'; import logger from '@/lib/logger'; +import { getAuthProvider } from './config'; import type { LocalUser } from './types'; // Server-side auth utilities for SSR pages // This file should only be imported in server components let stackServerApp: StackServerApp | null = null; -const OSS_TOKEN_COOKIE = 'dograh_oss_token'; -const OSS_USER_COOKIE = 'dograh_oss_user'; +const OSS_TOKEN_COOKIE = 'dograh_auth_token'; +const OSS_USER_COOKIE = 'dograh_auth_user'; // Lazy load and cache the stack server app -async function getStackServerApp(): Promise | null> { +export async function getStackServerApp(): Promise | null> { if (!stackServerApp) { // Only import if using Stack provider - const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack'; + const authProvider = await getAuthProvider(); if (authProvider === 'stack') { const stackModule = await import('@stackframe/stack'); const { StackServerApp } = stackModule; @@ -38,7 +39,7 @@ async function getStackServerApp(): Promise | nu * Returns CurrentUser for stack, LocalUser for OSS, or null if not authenticated */ export async function getServerUser(): Promise { - const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack'; + const authProvider = await getAuthProvider(); if (authProvider === 'stack') { const app = await getStackServerApp(); @@ -60,31 +61,12 @@ export async function getServerUser(): Promise { return null; } -/** - * Check if user is authenticated on the server side - * For local provider, always returns true in development - */ -export async function isServerAuthenticated(): Promise { - const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack'; - - if (authProvider === 'stack') { - const user = await getServerUser(); - return !!user; - } - - // For local provider, consider authenticated in development - if (authProvider === 'local') { - return process.env.NODE_ENV === 'development'; - } - - return false; -} /** * Get provider name for server-side rendering */ -export function getServerAuthProvider(): string { - return process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack'; +export async function getServerAuthProvider(): Promise { + return getAuthProvider(); } /** @@ -105,14 +87,22 @@ export async function getOSSUser(): Promise { if (userCookie) { try { - return JSON.parse(userCookie); + const parsed = JSON.parse(userCookie); + // Handle both legacy format and new JWT format + return { + id: String(parsed.id), + name: parsed.name || parsed.email || 'Local User', + email: parsed.email, + provider: 'local', + organizationId: parsed.organizationId || (parsed.organization_id ? String(parsed.organization_id) : undefined), + }; } catch (error) { - logger.error('Error listing permissions:', error); + logger.error('Error parsing user cookie:', error); return null; } } - // If no user cookie, but we have a token, create user + // If no user cookie, but we have a token, create user from token const token = cookieStore.get(OSS_TOKEN_COOKIE)?.value; if (token) { const user: LocalUser = { @@ -131,7 +121,7 @@ export async function getOSSUser(): Promise { * Get access token for API calls */ export async function getServerAccessToken(): Promise { - const authProvider = getServerAuthProvider(); + const authProvider = await getServerAuthProvider(); if (authProvider === 'stack') { const user = await getServerUser(); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 294332d..ef50715 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -104,12 +104,6 @@ export async function getRedirectUrl(token: string, permissions: { id: string }[ } } -/** - * Check if the application is running in OSS (Open Source Software) mode - */ -export function isOSSMode(): boolean { - return process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss'; -} /** * -------------------------------------------------------------------------- diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts index 1edfb5e..f73231a 100644 --- a/ui/src/middleware.ts +++ b/ui/src/middleware.ts @@ -1,53 +1,57 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -const OSS_TOKEN_COOKIE = 'dograh_oss_token'; -const OSS_USER_COOKIE = 'dograh_oss_user'; +const OSS_TOKEN_COOKIE = 'dograh_auth_token'; -function generateOSSToken(): string { - return `oss_${Date.now()}_${crypto.randomUUID()}`; +// Paths that don't require authentication in OSS mode +const PUBLIC_PATHS = ['/auth/login', '/auth/signup']; + +let cachedAuthProvider: string | null = null; + +async function fetchAuthProvider(): Promise { + if (cachedAuthProvider) { + return cachedAuthProvider; + } + + try { + const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; + const res = await fetch(`${backendUrl}/api/v1/health`); + if (res.ok) { + const data = await res.json(); + cachedAuthProvider = (data.auth_provider as string) || 'local'; + return cachedAuthProvider; + } + } catch { + // Backend not reachable — fall back to local + } + + cachedAuthProvider = 'local'; + return cachedAuthProvider; } -export function middleware(request: NextRequest) { - const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack'; +export async function middleware(request: NextRequest) { + const authProvider = await fetchAuthProvider(); // Only handle OSS mode if (authProvider !== 'local') { return NextResponse.next(); } - const response = NextResponse.next(); const token = request.cookies.get(OSS_TOKEN_COOKIE)?.value; + const { pathname } = request.nextUrl; - // If no token exists, create one - if (!token) { - const newToken = generateOSSToken(); - const user = { - id: newToken, - name: 'Local User', - provider: 'local', - organizationId: `org_${newToken}`, - }; - - // Set cookies in the response (httpOnly for security) - response.cookies.set(OSS_TOKEN_COOKIE, newToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30, // 30 days - path: '/', - }); - - response.cookies.set(OSS_USER_COOKIE, JSON.stringify(user), { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30, // 30 days - path: '/', - }); + // Allow public paths without auth + if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { + return NextResponse.next(); } - return response; + // If no token, redirect to login + if (!token) { + const loginUrl = new URL('/auth/login', request.url); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); } // Configure which routes the middleware runs on diff --git a/ui/src/stack.tsx b/ui/src/stack.tsx deleted file mode 100644 index 5be127e..0000000 --- a/ui/src/stack.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import "server-only"; - -import { StackServerApp } from "@stackframe/stack"; - -const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER; - -function createStackApp() { - if (authProvider === "local") { - // Return a dummy object when using local auth to prevent build errors - return {} as StackServerApp; - } - // Only initialize Stack Auth when actually using it - return new StackServerApp({ - tokenStore: "nextjs-cookie", - urls: { - afterSignIn: "/after-sign-in" - } - }); -} - -export const stackServerApp = createStackApp();