Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 166 additions & 131 deletions backend/app/api/admin.py

Large diffs are not rendered by default.

88 changes: 59 additions & 29 deletions backend/app/api/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@

# ─── Schemas ────────────────────────────────────────────


class TenantCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
slug: str | None = None
target_tenant_id: uuid.UUID | None = None


class TenantOut(BaseModel):
id: uuid.UUID
name: str
Expand All @@ -53,6 +56,7 @@ class TenantUpdate(BaseModel):

# ─── Helpers ────────────────────────────────────────────


def _slugify(name: str) -> str:
"""Generate a URL-friendly slug from a company name."""
# Replace CJK and non-alphanumeric chars with hyphens
Expand All @@ -67,6 +71,7 @@ def _slugify(name: str) -> str:

class SelfCreateResponse(BaseModel):
"""Response for self-create company, includes token for context switching."""

tenant: TenantOut
access_token: str | None = None # Non-null when a new User record was created (multi-tenant switch)

Expand All @@ -85,23 +90,38 @@ async def self_create_company(
"""
# Block self-creation if locked to a specific tenant (Dedicated Link flow)
if data.target_tenant_id is not None:
raise HTTPException(status_code=403, detail="Company creation is not allowed via this link. Please join your assigned organization.")
raise HTTPException(
status_code=403,
detail="Company creation is not allowed via this link. Please join your assigned organization.",
)

# Check if self-creation is allowed
from app.models.system_settings import SystemSetting
setting = await db.execute(
select(SystemSetting).where(SystemSetting.key == "allow_self_create_company")
)

setting = await db.execute(select(SystemSetting).where(SystemSetting.key == "allow_self_create_company"))
s = setting.scalar_one_or_none()
allowed = s.value.get("enabled", True) if s else True
if not allowed and current_user.role != "platform_admin":
raise HTTPException(status_code=403, detail="Company self-creation is currently disabled")

slug = _slugify(data.name)
if data.slug:
import re

slug = re.sub(r"[^a-z0-9]+", "-", data.slug.lower().strip()).strip("-")[:40]
if not slug:
slug = "company"
else:
slug = _slugify(data.name)
tenant = Tenant(name=data.name, slug=slug, im_provider="web_only")
db.add(tenant)
await db.flush()

from app.services.platform_service import platform_service

sso_base = await platform_service.get_tenant_sso_base_url(db, tenant)
tenant.sso_domain = sso_base
await db.flush()

access_token = None

if current_user.tenant_id is not None:
Expand All @@ -126,12 +146,14 @@ async def self_create_company(
await db.flush()

# Create Participant for the new user record
db.add(Participant(
type="user",
ref_id=new_user.id,
display_name=new_user.display_name,
avatar_url=new_user.avatar_url,
))
db.add(
Participant(
type="user",
ref_id=new_user.id,
display_name=new_user.display_name,
avatar_url=new_user.avatar_url,
)
)
await db.flush()

# Generate token scoped to the new user so frontend can switch context
Expand All @@ -155,6 +177,7 @@ async def self_create_company(

# ─── Self-Service: Join Company via Invite Code ─────────


class JoinRequest(BaseModel):
invitation_code: str = Field(min_length=1, max_length=32)
target_tenant_id: uuid.UUID | None = None
Expand All @@ -178,6 +201,7 @@ async def join_company(
- Registration flow (user has no tenant yet): assigns tenant directly
- Switch-org flow (user already has a tenant): creates a new User record"""
from app.models.invitation_code import InvitationCode

ic_result = await db.execute(
select(InvitationCode).where(
InvitationCode.code == data.invitation_code,
Expand All @@ -191,7 +215,9 @@ async def join_company(

# Verify matching tenant if locked (Dedicated Link flow)
if data.target_tenant_id and str(code_obj.tenant_id) != str(data.target_tenant_id):
raise HTTPException(status_code=403, detail="This invitation code does not belong to the required organization.")
raise HTTPException(
status_code=403, detail="This invitation code does not belong to the required organization."
)

if code_obj.used_count >= code_obj.max_uses:
raise HTTPException(status_code=400, detail="Invitation code has reached its usage limit")
Expand All @@ -214,7 +240,9 @@ async def join_company(

# Check if this company has an org_admin already
admin_check = await db.execute(
select(sqla_func.count()).select_from(User).where(
select(sqla_func.count())
.select_from(User)
.where(
User.tenant_id == tenant.id,
User.role.in_(["org_admin", "platform_admin"]),
)
Expand Down Expand Up @@ -248,12 +276,14 @@ async def join_company(
await db.flush()

# Create Participant for the new user record
db.add(Participant(
type="user",
ref_id=new_user.id,
display_name=new_user.display_name,
avatar_url=new_user.avatar_url,
))
db.add(
Participant(
type="user",
ref_id=new_user.id,
display_name=new_user.display_name,
avatar_url=new_user.avatar_url,
)
)
await db.flush()

# Generate token scoped to the new user so frontend can switch context
Expand Down Expand Up @@ -284,20 +314,21 @@ async def join_company(

# ─── Registration Config ───────────────────────────────


@router.get("/registration-config")
async def get_registration_config(db: AsyncSession = Depends(get_db)):
"""Public — returns whether self-creation of companies is allowed."""
from app.models.system_settings import SystemSetting
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == "allow_self_create_company")
)

result = await db.execute(select(SystemSetting).where(SystemSetting.key == "allow_self_create_company"))
s = result.scalar_one_or_none()
allowed = s.value.get("enabled", True) if s else True
return {"allow_self_create_company": allowed}


# ─── Public: Resolve Tenant by Domain ───────────────────


@router.get("/resolve-by-domain")
async def resolve_tenant_by_domain(
domain: str,
Expand All @@ -317,9 +348,7 @@ async def resolve_tenant_by_domain(
# 1. Match by stripping protocol from stored sso_domain
# sso_domain = "https://acme.clawith.ai" → compare against "acme.clawith.ai"
for proto in ("https://", "http://"):
result = await db.execute(
select(Tenant).where(Tenant.sso_domain == f"{proto}{domain}")
)
result = await db.execute(select(Tenant).where(Tenant.sso_domain == f"{proto}{domain}"))
tenant = result.scalar_one_or_none()
if tenant:
break
Expand All @@ -328,16 +357,15 @@ async def resolve_tenant_by_domain(
if not tenant and ":" in domain:
domain_no_port = domain.split(":")[0]
for proto in ("https://", "http://"):
result = await db.execute(
select(Tenant).where(Tenant.sso_domain.like(f"{proto}{domain_no_port}%"))
)
result = await db.execute(select(Tenant).where(Tenant.sso_domain.like(f"{proto}{domain_no_port}%")))
tenant = result.scalar_one_or_none()
if tenant:
break

# 3. Fallback: extract slug from subdomain pattern
if not tenant:
import re

m = re.match(r"^([a-z0-9][a-z0-9\-]*[a-z0-9])\.clawith\.ai$", domain.lower())
if m:
slug = m.group(1)
Expand All @@ -356,8 +384,10 @@ async def resolve_tenant_by_domain(
"is_active": tenant.is_active,
}


# ─── Authenticated: List / Get ──────────────────────────


@router.get("/", response_model=list[TenantOut])
async def list_tenants(
current_user: User = Depends(require_role("platform_admin")),
Expand Down Expand Up @@ -402,7 +432,7 @@ async def update_tenant(
raise HTTPException(status_code=404, detail="Tenant not found")

update_data = data.model_dump(exclude_unset=True)

# SSO configuration is managed exclusively by the company's own org_admin
# via the Enterprise Settings page. Platform admins should not override it here.
if current_user.role == "platform_admin":
Expand Down
56 changes: 56 additions & 0 deletions backend/app/core/public_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Utility functions for getting platform public URL."""

import os
from urllib.parse import urlparse


def get_public_base_url_sync() -> str:
"""Get the platform public base URL (sync version - only checks env var).

For async version with database lookup, use get_public_base_url_async().
"""
env_url = os.environ.get("PUBLIC_BASE_URL", "").strip()
if env_url:
return env_url.rstrip("/")
return ""


async def get_public_base_url_async(db) -> str:
"""Get the platform public base URL from database.

Args:
db: Database session

Returns:
The public base URL or empty string if not set
"""
try:
from sqlalchemy import select
from app.models.system_settings import SystemSetting

result = await db.execute(select(SystemSetting).where(SystemSetting.key == "platform"))
setting = result.scalar_one_or_none()
if setting and setting.value:
url = setting.value.get("public_base_url", "")
if url:
return url.rstrip("/")
except Exception:
pass
return ""


def get_sso_domain_from_slug(slug: str, public_url: str = "") -> str:
"""Generate SSO domain from slug using the platform public URL.

Args:
slug: The tenant slug (subdomain)
public_url: Optional pre-fetched public URL

Returns:
Full SSO domain like "slug.example.com" or "slug.example.com:3008"
"""
if public_url:
parsed = urlparse(public_url)
return f"{slug}.{parsed.netloc}"
else:
return f"{slug}.clawith.ai"
Loading