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:
Abhishek 2026-02-20 18:21:24 +05:30 committed by GitHub
parent 0791975864
commit 642cc34e8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 994 additions and 303 deletions

View file

@ -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 ###

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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
View 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,
)

View file

@ -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
View 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

View file

@ -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
View 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"])

View file

@ -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(

View file

@ -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}")

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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);
} }

View file

@ -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">

View 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 });
}

View file

@ -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' },
}); });
} }

View 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 });
}

View 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 });
}

View file

@ -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,
}); });
} }

View 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&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View 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>
);
}

View file

@ -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}
/>; />;
} }

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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>
); );
} }

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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",

View file

@ -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) {

View file

@ -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(() => {

View file

@ -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

View file

@ -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
View 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;
}

View file

@ -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>

View file

@ -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,

View file

@ -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();

View file

@ -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';
}
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------

View file

@ -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

View file

@ -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();