mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-04 10:52:17 +02:00
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
This commit is contained in:
parent
0791975864
commit
642cc34e8c
48 changed files with 994 additions and 303 deletions
|
|
@ -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 ###
|
||||||
|
|
@ -25,6 +25,7 @@ DATABASE_URL = os.environ["DATABASE_URL"]
|
||||||
REDIS_URL = os.environ["REDIS_URL"]
|
REDIS_URL = os.environ["REDIS_URL"]
|
||||||
|
|
||||||
DEPLOYMENT_MODE = os.getenv("DEPLOYMENT_MODE", "oss")
|
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)
|
DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None)
|
||||||
MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com")
|
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_PORT = int(os.getenv("TURN_PORT", "3478"))
|
||||||
TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349"))
|
TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349"))
|
||||||
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))
|
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
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ class UserModel(Base):
|
||||||
back_populates="users",
|
back_populates="users",
|
||||||
)
|
)
|
||||||
is_superuser = Column(Boolean, default=False)
|
is_superuser = Column(Boolean, default=False)
|
||||||
|
email = Column(String, unique=True, index=True, nullable=True)
|
||||||
|
password_hash = Column(String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class UserConfigurationModel(Base):
|
class UserConfigurationModel(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
@ -148,3 +149,35 @@ class UserClient(BaseDBClient):
|
||||||
raise ValueError(f"User with ID {user_id} not found")
|
raise ValueError(f"User with ID {user_id} not found")
|
||||||
|
|
||||||
await session.commit()
|
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
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,5 @@ sqlalchemy[asyncio]==2.0.43
|
||||||
msgpack==1.1.2
|
msgpack==1.1.2
|
||||||
docling[rapidocr]==2.68.0
|
docling[rapidocr]==2.68.0
|
||||||
pgvector==0.4.2
|
pgvector==0.4.2
|
||||||
|
bcrypt==5.0.0
|
||||||
|
email-validator==2.3.0
|
||||||
|
|
|
||||||
97
api/routes/auth.py
Normal file
97
api/routes/auth.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
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.campaign import router as campaign_router
|
||||||
from api.routes.credentials import router as credentials_router
|
from api.routes.credentials import router as credentials_router
|
||||||
from api.routes.integration import router as integration_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(public_download_router)
|
||||||
router.include_router(workflow_embed_router)
|
router.include_router(workflow_embed_router)
|
||||||
router.include_router(knowledge_base_router)
|
router.include_router(knowledge_base_router)
|
||||||
|
router.include_router(auth_router)
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str
|
version: str
|
||||||
backend_api_endpoint: str
|
backend_api_endpoint: str
|
||||||
|
deployment_mode: str
|
||||||
|
auth_provider: str
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse)
|
@router.get("/health", response_model=HealthResponse)
|
||||||
async def health() -> 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
|
from api.utils.common import get_backend_endpoints
|
||||||
|
|
||||||
logger.debug("Health endpoint called")
|
logger.debug("Health endpoint called")
|
||||||
|
|
@ -69,4 +73,6 @@ async def health() -> HealthResponse:
|
||||||
status="ok",
|
status="ok",
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
backend_api_endpoint=backend_endpoint,
|
backend_api_endpoint=backend_endpoint,
|
||||||
|
deployment_mode=DEPLOYMENT_MODE,
|
||||||
|
auth_provider=AUTH_PROVIDER,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
31
api/schemas/auth.py
Normal file
31
api/schemas/auth.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -5,12 +5,13 @@ from fastapi import Header, HTTPException, Query, WebSocket
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import ValidationError
|
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 import db_client
|
||||||
from api.db.models import UserModel
|
from api.db.models import UserModel
|
||||||
from api.schemas.user_configuration import UserConfiguration
|
from api.schemas.user_configuration import UserConfiguration
|
||||||
from api.services.auth.stack_auth import stackauth
|
from api.services.auth.stack_auth import stackauth
|
||||||
from api.services.configuration.registry import ServiceProviders
|
from api.services.configuration.registry import ServiceProviders
|
||||||
|
from api.utils.auth import decode_jwt_token
|
||||||
|
|
||||||
|
|
||||||
async def get_user(
|
async def get_user(
|
||||||
|
|
@ -24,9 +25,9 @@ async def get_user(
|
||||||
return await _handle_api_key_auth(x_api_key)
|
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)
|
return await _handle_oss_auth(authorization)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -54,6 +55,14 @@ async def get_user(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_model = await db_client.get_or_create_user_by_provider_id(stack_user["id"])
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Error while creating user from database {e}"
|
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:
|
async def _handle_oss_auth(authorization: str | None) -> UserModel:
|
||||||
"""
|
"""
|
||||||
Handle authentication for OSS deployment mode.
|
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:
|
if not authorization:
|
||||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
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")
|
raise HTTPException(status_code=401, detail="Invalid authorization token")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use token as provider_id for OSS mode
|
payload = decode_jwt_token(token)
|
||||||
user_model = await db_client.get_or_create_user_by_provider_id(
|
user = await db_client.get_user_by_id(int(payload["sub"]))
|
||||||
provider_id=token
|
if user:
|
||||||
)
|
return user
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
# Create or get organization for OSS user
|
except HTTPException:
|
||||||
# Each OSS user gets their own organization using their token as org ID
|
raise
|
||||||
(
|
except Exception:
|
||||||
organization,
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_api_key_auth(api_key: str) -> UserModel:
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
# Use MPS API URL from constants
|
# Use MPS API URL from constants
|
||||||
if DEPLOYMENT_MODE == "oss":
|
if AUTH_PROVIDER == "local":
|
||||||
# For OSS mode, create a temporary service key without authentication
|
# For local auth mode, create a temporary service key without authentication
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{MPS_API_URL}/api/v1/service-keys/",
|
f"{MPS_API_URL}/api/v1/service-keys/",
|
||||||
json={
|
json={
|
||||||
|
|
|
||||||
28
api/utils/auth.py
Normal file
28
api/utils/auth.py
Normal file
|
|
@ -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"])
|
||||||
|
|
@ -119,10 +119,6 @@ async def get_backend_endpoints() -> tuple[str, str]:
|
||||||
_validate_url(BACKEND_API_ENDPOINT)
|
_validate_url(BACKEND_API_ENDPOINT)
|
||||||
|
|
||||||
if 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
|
# 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:
|
if "localhost" in BACKEND_API_ENDPOINT or "127.0.0.1" in BACKEND_API_ENDPOINT:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ class TunnelURLProvider:
|
||||||
# Try to get URL from cloudflared metrics
|
# Try to get URL from cloudflared metrics
|
||||||
urls = await cls._get_cloudflared_urls()
|
urls = await cls._get_cloudflared_urls()
|
||||||
if urls:
|
if urls:
|
||||||
logger.info(f"Retrieved tunnel URLs from cloudflared: {urls}")
|
|
||||||
return urls
|
return urls
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get tunnel URL from cloudflared: {e}")
|
logger.warning(f"Failed to get tunnel URL from cloudflared: {e}")
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,9 @@ services:
|
||||||
MINIO_BUCKET: "voice-audio"
|
MINIO_BUCKET: "voice-audio"
|
||||||
MINIO_SECURE: "false"
|
MINIO_SECURE: "false"
|
||||||
|
|
||||||
|
# FastAPI workers count
|
||||||
|
FASTAPI_WORKERS: 1
|
||||||
|
|
||||||
# Langfuse
|
# Langfuse
|
||||||
ENABLE_TRACING: "false"
|
ENABLE_TRACING: "false"
|
||||||
# LANGFUSE_SECRET_KEY: ""
|
# LANGFUSE_SECRET_KEY: ""
|
||||||
|
|
@ -118,6 +121,8 @@ services:
|
||||||
TURN_HOST: "${TURN_HOST:-}"
|
TURN_HOST: "${TURN_HOST:-}"
|
||||||
TURN_SECRET: "${TURN_SECRET:-}"
|
TURN_SECRET: "${TURN_SECRET:-}"
|
||||||
|
|
||||||
|
OSS_JWT_SECRET: "${OSS_JWT_SECRET:-ChangeMeInProduction}"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -148,7 +153,6 @@ services:
|
||||||
# Server-side URL (SSR, internal Docker network)
|
# Server-side URL (SSR, internal Docker network)
|
||||||
BACKEND_URL: "http://api:8000"
|
BACKEND_URL: "http://api:8000"
|
||||||
NODE_ENV: "oss"
|
NODE_ENV: "oss"
|
||||||
|
|
||||||
# Flag to enable/ disable posthog and sentry
|
# Flag to enable/ disable posthog and sentry
|
||||||
ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
|
ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
|
||||||
|
|
||||||
|
|
@ -214,4 +218,4 @@ volumes:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,8 @@ TURN_EOF
|
||||||
echo -e "${GREEN}✓ turnserver.conf created${NC}"
|
echo -e "${GREEN}✓ turnserver.conf created${NC}"
|
||||||
|
|
||||||
echo -e "${BLUE}[6/6] Creating environment file...${NC}"
|
echo -e "${BLUE}[6/6] Creating environment file...${NC}"
|
||||||
|
OSS_JWT_SECRET=$(openssl rand -hex 32)
|
||||||
|
|
||||||
cat > .env << ENV_EOF
|
cat > .env << ENV_EOF
|
||||||
# Backend API endpoint (for remote deployment)
|
# Backend API endpoint (for remote deployment)
|
||||||
BACKEND_API_ENDPOINT=https://$SERVER_IP
|
BACKEND_API_ENDPOINT=https://$SERVER_IP
|
||||||
|
|
@ -175,6 +177,9 @@ BACKEND_API_ENDPOINT=https://$SERVER_IP
|
||||||
TURN_HOST=$SERVER_IP
|
TURN_HOST=$SERVER_IP
|
||||||
TURN_SECRET=$TURN_SECRET
|
TURN_SECRET=$TURN_SECRET
|
||||||
|
|
||||||
|
# JWT secret for OSS authentication
|
||||||
|
OSS_JWT_SECRET=$OSS_JWT_SECRET
|
||||||
|
|
||||||
# Telemetry (set to false to disable)
|
# Telemetry (set to false to disable)
|
||||||
ENABLE_TELEMETRY=true
|
ENABLE_TELEMETRY=true
|
||||||
ENV_EOF
|
ENV_EOF
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,2 @@
|
||||||
NEXT_PUBLIC_BACKEND_URL=http://localhost:8000
|
|
||||||
BACKEND_URL=http://localhost:8000
|
BACKEND_URL=http://localhost:8000
|
||||||
|
|
||||||
NEXT_PUBLIC_NODE_ENV=development
|
NEXT_PUBLIC_NODE_ENV=development
|
||||||
NEXT_PUBLIC_DEPLOYMENT_MODE: "oss"
|
|
||||||
NEXT_PUBLIC_AUTH_PROVIDER="local"
|
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,10 @@ COPY ui/src ./src
|
||||||
|
|
||||||
# Set build-time environment variables (needed for Next.js build)
|
# Set build-time environment variables (needed for Next.js build)
|
||||||
ENV NEXT_PUBLIC_NODE_ENV="oss"
|
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_TELEMETRY_DISABLED="1"
|
||||||
ENV NEXT_PUBLIC_CHATWOOT_URL="https://chat.dograh.com"
|
ENV NEXT_PUBLIC_CHATWOOT_URL="https://chat.dograh.com"
|
||||||
ENV NEXT_PUBLIC_CHATWOOT_TOKEN="3fkFx2mCEjNHjM9gaNc4A82X"
|
ENV NEXT_PUBLIC_CHATWOOT_TOKEN="3fkFx2mCEjNHjM9gaNc4A82X"
|
||||||
|
ENV BACKEND_URL="http://api:8000"
|
||||||
|
|
||||||
# Build the application with standalone mode
|
# Build the application with standalone mode
|
||||||
# Increase Node.js heap size to prevent out-of-memory errors during build
|
# Increase Node.js heap size to prevent out-of-memory errors during build
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { isNextRouterError } from "next/dist/client/components/is-next-router-error";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
|
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
|
||||||
|
|
@ -9,7 +10,7 @@ export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function AfterSignInPage() {
|
export default async function AfterSignInPage() {
|
||||||
logger.debug('[AfterSignInPage] Starting after-sign-in page');
|
logger.debug('[AfterSignInPage] Starting after-sign-in page');
|
||||||
const authProvider = getServerAuthProvider();
|
const authProvider = await getServerAuthProvider();
|
||||||
logger.debug('[AfterSignInPage] Auth provider:', authProvider);
|
logger.debug('[AfterSignInPage] Auth provider:', authProvider);
|
||||||
logger.debug('[AfterSignInPage] Getting server user...');
|
logger.debug('[AfterSignInPage] Getting server user...');
|
||||||
const user = await getServerUser();
|
const user = await getServerUser();
|
||||||
|
|
@ -54,6 +55,9 @@ export default async function AfterSignInPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isNextRouterError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
logger.error('[AfterSignInPage] Error checking workflows:', error);
|
logger.error('[AfterSignInPage] Error checking workflows:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useAppConfig } from '@/context/AppConfigContext';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import logger from '@/lib/logger';
|
import logger from '@/lib/logger';
|
||||||
import { isOSSMode } from '@/lib/utils';
|
|
||||||
|
|
||||||
export default function APIKeysPage() {
|
export default function APIKeysPage() {
|
||||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const isOSS = config?.deploymentMode === 'oss';
|
||||||
|
|
||||||
logger.debug('[APIKeysPage] Component render', {
|
logger.debug('[APIKeysPage] Component render', {
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -313,8 +315,8 @@ export default function APIKeysPage() {
|
||||||
|
|
||||||
// In OSS mode, check if there's already an active service key
|
// In OSS mode, check if there's already an active service key
|
||||||
const activeServiceKeys = serviceKeys.filter(key => !key.archived_at);
|
const activeServiceKeys = serviceKeys.filter(key => !key.archived_at);
|
||||||
const canCreateServiceKey = !isOSSMode() || activeServiceKeys.length === 0;
|
const canCreateServiceKey = !isOSS || activeServiceKeys.length === 0;
|
||||||
const showServiceKeyArchiveControls = !isOSSMode();
|
const showServiceKeyArchiveControls = !isOSS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
|
|
||||||
27
ui/src/app/api/auth/logout/route.ts
Normal file
27
ui/src/app/api/auth/logout/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
/*
|
/*
|
||||||
Provides authentication token to LocalProviderWrapper once loaded
|
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 { cookies } from 'next/headers';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
|
import { getAuthProvider } from '@/lib/auth/config';
|
||||||
const OSS_USER_COOKIE = 'dograh_oss_user';
|
|
||||||
|
|
||||||
function generateOSSToken(): string {
|
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
|
||||||
return `oss_${Date.now()}_${crypto.randomUUID()}`;
|
const OSS_USER_COOKIE = 'dograh_auth_user';
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
const authProvider = await getAuthProvider();
|
||||||
|
|
||||||
// Only handle OSS mode
|
// Only handle OSS mode
|
||||||
if (authProvider !== 'local') {
|
if (authProvider !== 'local') {
|
||||||
|
|
@ -21,40 +20,17 @@ export async function GET() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
let token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
|
const token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
|
||||||
let user = cookieStore.get(OSS_USER_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) {
|
if (!token) {
|
||||||
token = generateOSSToken();
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
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 the auth info as JSON (safe to expose to client)
|
// Return the auth info as JSON
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
token,
|
token,
|
||||||
user: JSON.parse(user!),
|
user: user ? JSON.parse(user) : { id: token, name: 'Local User', provider: 'local' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
ui/src/app/api/auth/session/route.ts
Normal file
33
ui/src/app/api/auth/session/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
8
ui/src/app/api/config/auth/route.ts
Normal file
8
ui/src/app/api/config/auth/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -6,28 +6,50 @@ import type { HealthResponse } from "@/client/types.gen";
|
||||||
// Import version from package.json at build time
|
// Import version from package.json at build time
|
||||||
import packageJson from "../../../../../package.json";
|
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() {
|
export async function GET() {
|
||||||
const uiVersion = packageJson.version || "dev";
|
const uiVersion = packageJson.version || "dev";
|
||||||
|
|
||||||
// Fetch backend version and config from health endpoint
|
// Fetch backend version and config from health endpoint
|
||||||
let apiVersion = "unknown";
|
let apiVersion = "unknown";
|
||||||
let backendApiEndpoint: string | null = null;
|
let backendApiEndpoint: string | null = null;
|
||||||
|
let deploymentMode = "oss";
|
||||||
|
let authProvider = "local";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await healthApiV1HealthGet();
|
const response = await healthApiV1HealthGet();
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
const data = response.data as HealthResponse;
|
const data = response.data as HealthResponse;
|
||||||
apiVersion = data.version;
|
apiVersion = data.version;
|
||||||
|
// Pass through the backend's own endpoint for display purposes
|
||||||
backendApiEndpoint = data.backend_api_endpoint;
|
backendApiEndpoint = data.backend_api_endpoint;
|
||||||
|
deploymentMode = data.deployment_mode;
|
||||||
|
authProvider = data.auth_provider;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Backend might not be reachable during build or in some deployments
|
// Backend might not be reachable during build or in some deployments
|
||||||
apiVersion = "unavailable";
|
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({
|
return NextResponse.json({
|
||||||
ui: uiVersion,
|
ui: uiVersion,
|
||||||
api: apiVersion,
|
api: apiVersion,
|
||||||
backendApiEndpoint,
|
backendApiEndpoint,
|
||||||
|
clientApiBaseUrl,
|
||||||
|
deploymentMode,
|
||||||
|
authProvider,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
93
ui/src/app/auth/login/page.tsx
Normal file
93
ui/src/app/auth/login/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Sign in</CardTitle>
|
||||||
|
<CardDescription>Enter your email and password to continue</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
ui/src/app/auth/signup/page.tsx
Normal file
118
ui/src/app/auth/signup/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||||
|
<CardDescription>Enter your details to get started</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Creating account..." : "Create account"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/auth/login" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { StackHandler } from "@stackframe/stack";
|
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") {
|
if (authProvider === "local") {
|
||||||
// Return a simple message when using local auth
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||||
<h1>Local Auth Mode</h1>
|
<h1>Local Auth Mode</h1>
|
||||||
|
|
@ -14,9 +13,14 @@ export default function Handler(props: unknown) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazily import the real StackServerApp only when needed
|
||||||
|
const { getStackServerApp } = await import("@/lib/auth/server");
|
||||||
|
const app = await getStackServerApp();
|
||||||
|
|
||||||
return <StackHandler
|
return <StackHandler
|
||||||
fullPage
|
fullPage
|
||||||
app={stackServerApp}
|
app={app!}
|
||||||
routeProps={props}
|
routeProps={props}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
// Server component for integration list
|
// Server component for integration list
|
||||||
async function IntegrationList() {
|
async function IntegrationList() {
|
||||||
const authProvider = getServerAuthProvider();
|
const authProvider = await getServerAuthProvider();
|
||||||
const accessToken = await getServerAccessToken();
|
const accessToken = await getServerAccessToken();
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ interface PageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function PageContent({ params }: PageProps) {
|
async function PageContent({ params }: PageProps) {
|
||||||
const authProvider = getServerAuthProvider();
|
const authProvider = await getServerAuthProvider();
|
||||||
const accessToken = await getServerAccessToken();
|
const accessToken = await getServerAccessToken();
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
logger.debug('[HomePage] Starting Home page render');
|
logger.debug('[HomePage] Starting Home page render');
|
||||||
const authProvider = getServerAuthProvider();
|
const authProvider = await getServerAuthProvider();
|
||||||
logger.debug('[HomePage] Auth provider:', authProvider);
|
logger.debug('[HomePage] Auth provider:', authProvider);
|
||||||
|
|
||||||
// For local/OSS provider, check if user has workflows
|
// 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');
|
logger.debug('[HomePage] Redirecting to /workflow/create - no workflows found');
|
||||||
redirect('/workflow/create');
|
redirect('/workflow/create');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
redirect('/auth/login');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Re-throw navigation errors (redirects, not found, etc.) - they're intentional
|
// Re-throw navigation errors (redirects, not found, etc.) - they're intentional
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
// Server component for workflow list
|
// Server component for workflow list
|
||||||
async function WorkflowList() {
|
async function WorkflowList() {
|
||||||
const authProvider = getServerAuthProvider();
|
const authProvider = await getServerAuthProvider();
|
||||||
const accessToken = await getServerAccessToken();
|
const accessToken = await getServerAccessToken();
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -80,6 +80,11 @@ export type AdminCommentResponse = {
|
||||||
admin_comment_ts: string;
|
admin_comment_ts: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthResponse = {
|
||||||
|
token: string;
|
||||||
|
user: UserResponse;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthUserResponse = {
|
export type AuthUserResponse = {
|
||||||
id: number;
|
id: number;
|
||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
|
|
@ -599,6 +604,8 @@ export type HealthResponse = {
|
||||||
status: string;
|
status: string;
|
||||||
version: string;
|
version: string;
|
||||||
backend_api_endpoint: 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 = {
|
export type PresignedUploadUrlRequest = {
|
||||||
/**
|
/**
|
||||||
* CSV filename
|
* CSV filename
|
||||||
|
|
@ -808,6 +820,12 @@ export type SessionResponse = {
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SignupRequest = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type SuperuserWorkflowRunResponse = {
|
export type SuperuserWorkflowRunResponse = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -1129,6 +1147,13 @@ export type UserConfigurationRequestResponseSchema = {
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserResponse = {
|
||||||
|
id: number;
|
||||||
|
email: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
organization_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ValidateWorkflowResponse = {
|
export type ValidateWorkflowResponse = {
|
||||||
is_valid: boolean;
|
is_valid: boolean;
|
||||||
errors: Array<WorkflowError>;
|
errors: Array<WorkflowError>;
|
||||||
|
|
@ -5018,6 +5043,97 @@ export type SearchChunksApiV1KnowledgeBaseSearchPostResponses = {
|
||||||
|
|
||||||
export type SearchChunksApiV1KnowledgeBaseSearchPostResponse = SearchChunksApiV1KnowledgeBaseSearchPostResponses[keyof 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 = {
|
export type HealthApiV1HealthGetData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
|
|
||||||
|
|
@ -12,16 +16,19 @@ const SignIn = dynamic(
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function SignInClient() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="text-center">
|
<Loader2 className="w-5 h-5 animate-spin text-gray-600" />
|
||||||
<h1 className="text-2xl font-bold mb-4">Local Authentication</h1>
|
|
||||||
<p className="text-gray-600">Local authentication is enabled. No sign-in required.</p>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { isOSSMode } from "@/lib/utils";
|
|
||||||
|
|
||||||
import { NodeContent } from "./common/NodeContent";
|
import { NodeContent } from "./common/NodeContent";
|
||||||
import { NodeEditDialog } from "./common/NodeEditDialog";
|
import { NodeEditDialog } from "./common/NodeEditDialog";
|
||||||
|
|
@ -350,21 +349,19 @@ const StartCallEditForm = ({
|
||||||
Add Global Prompt
|
Add Global Prompt
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{!isOSSMode() && (
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex items-center space-x-2">
|
<Switch
|
||||||
<Switch
|
id="detect-voicemail"
|
||||||
id="detect-voicemail"
|
checked={detectVoicemail}
|
||||||
checked={detectVoicemail}
|
onCheckedChange={setDetectVoicemail}
|
||||||
onCheckedChange={setDetectVoicemail}
|
/>
|
||||||
/>
|
<Label htmlFor="detect-voicemail">
|
||||||
<Label htmlFor="detect-voicemail">
|
Detect Voicemail
|
||||||
Detect Voicemail
|
</Label>
|
||||||
</Label>
|
<Label className="text-xs text-muted-foreground">
|
||||||
<Label className="text-xs text-muted-foreground">
|
Automatically detect and end call if voicemail is reached.
|
||||||
Automatically detect and end call if voicemail is reached.
|
</Label>
|
||||||
</Label>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Switch
|
<Switch
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,7 @@ export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
|
||||||
const [triggerPath] = useState(() => data.trigger_path ?? crypto.randomUUID());
|
const [triggerPath] = useState(() => data.trigger_path ?? crypto.randomUUID());
|
||||||
|
|
||||||
// Get backend URL from app config (fetched from backend health endpoint)
|
// 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 || "http://localhost:8000";
|
||||||
const backendUrl = config?.backendApiEndpoint || process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
|
|
||||||
const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`;
|
const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`;
|
||||||
|
|
||||||
// Copy state for button feedback
|
// Copy state for button feedback
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ const AppLayout: React.FC<AppLayoutProps> = ({
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Check if current route should have sidebar
|
// Check if current route should have sidebar
|
||||||
// Hide sidebar for root (/) and /handler routes (Stack Auth routes)
|
// Hide sidebar for root (/), /handler routes (Stack Auth routes), and /auth routes
|
||||||
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler");
|
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler") && !pathname.startsWith("/auth");
|
||||||
|
|
||||||
// Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default
|
// Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default
|
||||||
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
|
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
Database,
|
Database,
|
||||||
FileText,
|
FileText,
|
||||||
HelpCircle,
|
|
||||||
Home,
|
Home,
|
||||||
Key,
|
Key,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
|
@ -24,7 +23,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import React from "react";
|
import React, { useRef } from "react";
|
||||||
|
|
||||||
import ThemeToggle from "@/components/ThemeSwitcher";
|
import ThemeToggle from "@/components/ThemeSwitcher";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -57,6 +56,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useAppConfig } from "@/context/AppConfigContext";
|
import { useAppConfig } from "@/context/AppConfigContext";
|
||||||
|
import type { LocalUser } from "@/lib/auth";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -75,7 +75,14 @@ export function AppSidebar() {
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
|
|
||||||
// Get selected team for Stack auth (cast to Team type from Stack)
|
// 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<Team | null>(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
|
// Version info from app config context
|
||||||
const versionInfo = config ? { ui: config.uiVersion, api: config.apiVersion } : null;
|
const versionInfo = config ? { ui: config.uiVersion, api: config.apiVersion } : null;
|
||||||
|
|
@ -358,54 +365,45 @@ export function AppSidebar() {
|
||||||
)}>
|
)}>
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Get Help - for OSS mode */}
|
{/* User Button - for local/OSS mode */}
|
||||||
{provider !== "stack" && (
|
{provider !== "stack" && (
|
||||||
<>
|
<div className={cn(
|
||||||
{state === "collapsed" ? (
|
"flex",
|
||||||
<TooltipProvider delayDuration={0}>
|
state === "collapsed" ? "justify-center" : "justify-start"
|
||||||
<Tooltip>
|
)}>
|
||||||
<TooltipTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button variant="ghost" size="icon" className="rounded-full h-8 w-8 cursor-pointer">
|
||||||
size="icon"
|
<span className="text-xs font-medium">
|
||||||
className="w-full hover:bg-accent hover:text-accent-foreground"
|
{(user?.displayName || (user as LocalUser | undefined)?.email || "")
|
||||||
asChild
|
.split(/[\s@]/)
|
||||||
>
|
.filter(Boolean)
|
||||||
<a
|
.slice(0, 2)
|
||||||
href="https://github.com/dograh-hq/dograh/issues/new/choose"
|
.map((s: string) => s[0]?.toUpperCase())
|
||||||
target="_blank"
|
.join("")
|
||||||
rel="noopener noreferrer"
|
|| "U"}
|
||||||
>
|
</span>
|
||||||
<HelpCircle className="h-4 w-4" />
|
</Button>
|
||||||
<span className="sr-only">Get Help</span>
|
</DropdownMenuTrigger>
|
||||||
</a>
|
<DropdownMenuContent side="top" align="start" className="w-56">
|
||||||
</Button>
|
<DropdownMenuLabel className="font-normal">
|
||||||
</TooltipTrigger>
|
<div className="flex flex-col space-y-1">
|
||||||
<TooltipContent side="right">
|
{(user as LocalUser | undefined)?.email && (
|
||||||
<p>Get Help</p>
|
<p className="text-xs text-muted-foreground">{(user as LocalUser).email}</p>
|
||||||
</TooltipContent>
|
)}
|
||||||
</Tooltip>
|
</div>
|
||||||
</TooltipProvider>
|
</DropdownMenuLabel>
|
||||||
) : (
|
<DropdownMenuSeparator />
|
||||||
<Button
|
<DropdownMenuItem onClick={() => logout()} className="cursor-pointer">
|
||||||
variant="ghost"
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
className="w-full justify-start hover:bg-accent hover:text-accent-foreground"
|
Sign out
|
||||||
asChild
|
</DropdownMenuItem>
|
||||||
>
|
</DropdownMenuContent>
|
||||||
<a
|
</DropdownMenu>
|
||||||
href="https://github.com/dograh-hq/dograh/issues/new/choose"
|
</div>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-4 w-4" />
|
|
||||||
<span className="ml-2">Get Help</span>
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Button - at the bottom */}
|
{/* User Button - for Stack auth */}
|
||||||
{provider === "stack" && (
|
{provider === "stack" && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex",
|
"flex",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useAppConfig } from '@/context/AppConfigContext';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import logger from '@/lib/logger';
|
import logger from '@/lib/logger';
|
||||||
|
|
||||||
|
|
@ -45,6 +46,7 @@ export function LiveAudioPlayer({
|
||||||
const nextStartTimeRef = useRef(0);
|
const nextStartTimeRef = useRef(0);
|
||||||
const animationFrameRef = useRef<number | undefined>(undefined);
|
const animationFrameRef = useRef<number | undefined>(undefined);
|
||||||
const isConnectingRef = useRef(false);
|
const isConnectingRef = useRef(false);
|
||||||
|
const { config } = useAppConfig();
|
||||||
const { user, getAccessToken } = useAuth();
|
const { user, getAccessToken } = useAuth();
|
||||||
|
|
||||||
// Auto-start streaming when session starts
|
// Auto-start streaming when session starts
|
||||||
|
|
@ -98,7 +100,7 @@ export function LiveAudioPlayer({
|
||||||
const accessToken = await getAccessToken();
|
const accessToken = await getAccessToken();
|
||||||
|
|
||||||
// Create WebSocket connection
|
// 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 wsUrl = `${baseUrl}/api/v1/looptalk/test-sessions/${testSessionId}/audio-stream?role=${audioRole}&token=${encodeURIComponent(accessToken || '')}`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
@ -199,7 +201,7 @@ export function LiveAudioPlayer({
|
||||||
} finally {
|
} finally {
|
||||||
isConnectingRef.current = false;
|
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(() => {
|
const disconnect = useCallback(() => {
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@
|
||||||
|
|
||||||
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { client } from '@/client/client.gen';
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
uiVersion: string;
|
uiVersion: string;
|
||||||
apiVersion: string;
|
apiVersion: string;
|
||||||
backendApiEndpoint: string | null;
|
backendApiEndpoint: string | null;
|
||||||
|
deploymentMode: string;
|
||||||
|
authProvider: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppConfigContextType {
|
interface AppConfigContextType {
|
||||||
|
|
@ -17,6 +21,8 @@ const defaultConfig: AppConfig = {
|
||||||
uiVersion: 'dev',
|
uiVersion: 'dev',
|
||||||
apiVersion: 'unknown',
|
apiVersion: 'unknown',
|
||||||
backendApiEndpoint: null,
|
backendApiEndpoint: null,
|
||||||
|
deploymentMode: 'oss',
|
||||||
|
authProvider: 'local',
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppConfigContext = createContext<AppConfigContextType>({
|
const AppConfigContext = createContext<AppConfigContextType>({
|
||||||
|
|
@ -32,10 +38,17 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
|
||||||
fetch('/api/config/version')
|
fetch('/api/config/version')
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.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({
|
setConfig({
|
||||||
uiVersion: data.ui || 'dev',
|
uiVersion: data.ui || 'dev',
|
||||||
apiVersion: data.api || 'unknown',
|
apiVersion: data.api || 'unknown',
|
||||||
backendApiEndpoint: data.backendApiEndpoint || null,
|
backendApiEndpoint: data.backendApiEndpoint || null,
|
||||||
|
deploymentMode: data.deploymentMode || 'oss',
|
||||||
|
authProvider: data.authProvider || 'local',
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { client } from '@/client/client.gen';
|
||||||
import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
|
import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
|
||||||
import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||||
import { setupAuthInterceptor } from '@/lib/apiClient';
|
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.
|
// so it's in place before any child effects fire API calls.
|
||||||
// setupAuthInterceptor is idempotent — safe for strict mode double-renders.
|
// setupAuthInterceptor is idempotent — safe for strict mode double-renders.
|
||||||
if (!auth.loading && auth.isAuthenticated) {
|
if (!auth.loading && auth.isAuthenticated) {
|
||||||
setupAuthInterceptor(auth.getAccessToken);
|
setupAuthInterceptor(client, auth.getAccessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch permissions once when auth is ready
|
// Fetch permissions once when auth is ready
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
import type { Client } from '@hey-api/client-fetch';
|
||||||
|
|
||||||
import type { CreateClientConfig } from '@/client/client.gen';
|
import type { CreateClientConfig } from '@/client/client.gen';
|
||||||
import { client } from '@/client/client.gen';
|
|
||||||
|
|
||||||
export const createClientConfig: CreateClientConfig = (config) => {
|
export const createClientConfig: CreateClientConfig = (config) => {
|
||||||
// Use different URLs for server-side vs client-side
|
// 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
|
// for server-side rendering, still use environment variable as fallback
|
||||||
baseUrl = process.env.BACKEND_URL || 'http://api:8000';
|
baseUrl = process.env.BACKEND_URL || 'http://api:8000';
|
||||||
} else {
|
} else {
|
||||||
// for client-side, use the current browser URL's origin
|
// Client-side API calls are proxied through Next.js rewrites.
|
||||||
baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
|
// AppConfigContext may update this later with the fetched backend URL.
|
||||||
|
baseUrl = window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -26,11 +28,11 @@ let interceptorRegistered = false;
|
||||||
* Register a request interceptor that attaches a fresh access token
|
* Register a request interceptor that attaches a fresh access token
|
||||||
* to every outgoing SDK request. Idempotent — safe for React strict mode.
|
* to every outgoing SDK request. Idempotent — safe for React strict mode.
|
||||||
*/
|
*/
|
||||||
export function setupAuthInterceptor(getAccessToken: () => Promise<string>) {
|
export function setupAuthInterceptor(apiClient: Client, getAccessToken: () => Promise<string>) {
|
||||||
if (interceptorRegistered) return;
|
if (interceptorRegistered) return;
|
||||||
interceptorRegistered = true;
|
interceptorRegistered = true;
|
||||||
|
|
||||||
client.interceptors.request.use(async (request) => {
|
apiClient.interceptors.request.use(async (request) => {
|
||||||
if (request.headers.get('Authorization')) {
|
if (request.headers.get('Authorization')) {
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
ui/src/lib/auth/config.ts
Normal file
30
ui/src/lib/auth/config.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react';
|
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';
|
import type { AuthUser } from '../types';
|
||||||
|
|
||||||
|
|
@ -34,17 +34,30 @@ const LocalProviderWrapper = lazy(() =>
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const LoadingFallback = (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
const [authProvider, setAuthProvider] = useState<string | null>(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
|
// For Stack provider, use the dedicated wrapper
|
||||||
if (authProvider === 'stack') {
|
if (authProvider === 'stack') {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense fallback={LoadingFallback}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<StackProviderWrapper>
|
<StackProviderWrapper>
|
||||||
{children}
|
{children}
|
||||||
</StackProviderWrapper>
|
</StackProviderWrapper>
|
||||||
|
|
@ -54,11 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
// For local/OSS provider
|
// For local/OSS provider
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense fallback={LoadingFallback}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<LocalProviderWrapper>
|
<LocalProviderWrapper>
|
||||||
{children}
|
{children}
|
||||||
</LocalProviderWrapper>
|
</LocalProviderWrapper>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ export function LocalProviderWrapper({ children }: { children: React.ReactNode }
|
||||||
tokenRef.current = data.token;
|
tokenRef.current = data.token;
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
logger.info('OSS auth initialized', { user: 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 {
|
} else {
|
||||||
logger.error('Failed to initialize OSS auth');
|
logger.error('Failed to initialize OSS auth');
|
||||||
}
|
}
|
||||||
|
|
@ -48,17 +54,23 @@ export function LocalProviderWrapper({ children }: { children: React.ReactNode }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const redirectToLogin = React.useCallback(() => {
|
const redirectToLogin = React.useCallback(() => {
|
||||||
logger.info('Login redirect not needed in local mode');
|
window.location.href = '/auth/login';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = React.useCallback(async () => {
|
const logout = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during logout', error);
|
||||||
|
}
|
||||||
setUser(null);
|
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(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
user: user as AuthUser,
|
user: user as AuthUser,
|
||||||
isAuthenticated: !loading,
|
isAuthenticated: !!user,
|
||||||
loading,
|
loading,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
redirectToLogin,
|
redirectToLogin,
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,21 @@ import { cookies } from 'next/headers';
|
||||||
|
|
||||||
import logger from '@/lib/logger';
|
import logger from '@/lib/logger';
|
||||||
|
|
||||||
|
import { getAuthProvider } from './config';
|
||||||
import type { LocalUser } from './types';
|
import type { LocalUser } from './types';
|
||||||
|
|
||||||
// Server-side auth utilities for SSR pages
|
// Server-side auth utilities for SSR pages
|
||||||
// This file should only be imported in server components
|
// This file should only be imported in server components
|
||||||
|
|
||||||
let stackServerApp: StackServerApp<boolean, string> | null = null;
|
let stackServerApp: StackServerApp<boolean, string> | null = null;
|
||||||
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
|
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
|
||||||
const OSS_USER_COOKIE = 'dograh_oss_user';
|
const OSS_USER_COOKIE = 'dograh_auth_user';
|
||||||
|
|
||||||
// Lazy load and cache the stack server app
|
// Lazy load and cache the stack server app
|
||||||
async function getStackServerApp(): Promise<StackServerApp<boolean, string> | null> {
|
export async function getStackServerApp(): Promise<StackServerApp<boolean, string> | null> {
|
||||||
if (!stackServerApp) {
|
if (!stackServerApp) {
|
||||||
// Only import if using Stack provider
|
// Only import if using Stack provider
|
||||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
const authProvider = await getAuthProvider();
|
||||||
if (authProvider === 'stack') {
|
if (authProvider === 'stack') {
|
||||||
const stackModule = await import('@stackframe/stack');
|
const stackModule = await import('@stackframe/stack');
|
||||||
const { StackServerApp } = stackModule;
|
const { StackServerApp } = stackModule;
|
||||||
|
|
@ -38,7 +39,7 @@ async function getStackServerApp(): Promise<StackServerApp<boolean, string> | nu
|
||||||
* Returns CurrentUser for stack, LocalUser for OSS, or null if not authenticated
|
* Returns CurrentUser for stack, LocalUser for OSS, or null if not authenticated
|
||||||
*/
|
*/
|
||||||
export async function getServerUser(): Promise<CurrentUser | LocalUser | null> {
|
export async function getServerUser(): Promise<CurrentUser | LocalUser | null> {
|
||||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
const authProvider = await getAuthProvider();
|
||||||
|
|
||||||
if (authProvider === 'stack') {
|
if (authProvider === 'stack') {
|
||||||
const app = await getStackServerApp();
|
const app = await getStackServerApp();
|
||||||
|
|
@ -60,31 +61,12 @@ export async function getServerUser(): Promise<CurrentUser | LocalUser | null> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is authenticated on the server side
|
|
||||||
* For local provider, always returns true in development
|
|
||||||
*/
|
|
||||||
export async function isServerAuthenticated(): Promise<boolean> {
|
|
||||||
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
|
* Get provider name for server-side rendering
|
||||||
*/
|
*/
|
||||||
export function getServerAuthProvider(): string {
|
export async function getServerAuthProvider(): Promise<string> {
|
||||||
return process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
return getAuthProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -105,14 +87,22 @@ export async function getOSSUser(): Promise<LocalUser | null> {
|
||||||
|
|
||||||
if (userCookie) {
|
if (userCookie) {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
logger.error('Error listing permissions:', error);
|
logger.error('Error parsing user cookie:', error);
|
||||||
return null;
|
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;
|
const token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
|
||||||
if (token) {
|
if (token) {
|
||||||
const user: LocalUser = {
|
const user: LocalUser = {
|
||||||
|
|
@ -131,7 +121,7 @@ export async function getOSSUser(): Promise<LocalUser | null> {
|
||||||
* Get access token for API calls
|
* Get access token for API calls
|
||||||
*/
|
*/
|
||||||
export async function getServerAccessToken(): Promise<string | null> {
|
export async function getServerAccessToken(): Promise<string | null> {
|
||||||
const authProvider = getServerAuthProvider();
|
const authProvider = await getServerAuthProvider();
|
||||||
|
|
||||||
if (authProvider === 'stack') {
|
if (authProvider === 'stack') {
|
||||||
const user = await getServerUser();
|
const user = await getServerUser();
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,57 @@
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
|
const OSS_TOKEN_COOKIE = 'dograh_auth_token';
|
||||||
const OSS_USER_COOKIE = 'dograh_oss_user';
|
|
||||||
|
|
||||||
function generateOSSToken(): string {
|
// Paths that don't require authentication in OSS mode
|
||||||
return `oss_${Date.now()}_${crypto.randomUUID()}`;
|
const PUBLIC_PATHS = ['/auth/login', '/auth/signup'];
|
||||||
|
|
||||||
|
let cachedAuthProvider: string | null = null;
|
||||||
|
|
||||||
|
async function fetchAuthProvider(): Promise<string> {
|
||||||
|
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) {
|
export async function middleware(request: NextRequest) {
|
||||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
const authProvider = await fetchAuthProvider();
|
||||||
|
|
||||||
// Only handle OSS mode
|
// Only handle OSS mode
|
||||||
if (authProvider !== 'local') {
|
if (authProvider !== 'local') {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.next();
|
|
||||||
const token = request.cookies.get(OSS_TOKEN_COOKIE)?.value;
|
const token = request.cookies.get(OSS_TOKEN_COOKIE)?.value;
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// If no token exists, create one
|
// Allow public paths without auth
|
||||||
if (!token) {
|
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||||
const newToken = generateOSSToken();
|
return NextResponse.next();
|
||||||
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: '/',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Configure which routes the middleware runs on
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue