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
8 changes: 8 additions & 0 deletions backend/app/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from datetime import datetime

from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy import func as sqla_func, select
from sqlalchemy.ext.asyncio import AsyncSession
Expand Down Expand Up @@ -159,6 +160,13 @@ async def create_company(
db.add(invite)
await db.flush()

# Seed default agents (Morty & Meeseeks) for the new company
try:
from app.services.agent_seeder import seed_default_agents_for_tenant
await seed_default_agents_for_tenant(db=db, tenant_id=tenant.id, creator_id=current_user.id)
except Exception as e:
logger.warning(f"[create_company] Failed to seed default agents: {e}")

return CompanyCreateResponse(
company=CompanyStats(
id=tenant.id,
Expand Down
8 changes: 6 additions & 2 deletions backend/app/api/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -1239,8 +1239,10 @@ async def list_org_members(
# Auto-scope: use the user's own tenant when available
tenant_id = effective_tenant_id # None only for true global admin

query = select(OrgMember, IdentityProvider.name.label("provider_name"), IdentityProvider.provider_type).outerjoin(
query = select(OrgMember, IdentityProvider.name.label("provider_name"), IdentityProvider.provider_type, User.display_name.label("user_display_name")).outerjoin(
IdentityProvider, OrgMember.provider_id == IdentityProvider.id
).outerjoin(
User, OrgMember.user_id == User.id
).where(OrgMember.status == "active")
if tenant_id:
query = query.where(OrgMember.tenant_id == uuid.UUID(tenant_id))
Expand Down Expand Up @@ -1280,15 +1282,17 @@ async def list_org_members(
"id": str(m.id),
"name": m.name,
"email": m.email,
"phone": m.phone,
"title": m.title,
"department_path": m.department_path,
"avatar_url": m.avatar_url,
"external_id": m.external_id,
"provider_id": str(m.provider_id) if m.provider_id else None,
"provider_name": provider_name if m.provider_id else None,
"provider_type": provider_type if m.provider_id else None,
"user_display_name": user_display_name,
}
for m, provider_name, provider_type in rows
for m, provider_name, provider_type, user_display_name in rows
]


Expand Down
199 changes: 199 additions & 0 deletions backend/app/api/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from datetime import datetime

from fastapi import APIRouter, Depends, HTTPException, status
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy import func as sqla_func, select
from sqlalchemy.ext.asyncio import AsyncSession
Expand Down Expand Up @@ -185,6 +186,13 @@ async def self_create_company(
current_user.quota_agent_ttl_hours = tenant.default_agent_ttl_hours
await db.flush()

# Seed default agents (Morty & Meeseeks) for the new company
try:
from app.services.agent_seeder import seed_default_agents_for_tenant
await seed_default_agents_for_tenant(db=db, tenant_id=tenant.id, creator_id=current_user.id)
except Exception as e:
logger.warning(f"[self_create_company] Failed to seed default agents: {e}")

await db.commit()

return SelfCreateResponse(
Expand Down Expand Up @@ -484,3 +492,194 @@ async def assign_user_to_tenant(
user.role = role
await db.flush()
return {"status": "ok", "user_id": str(user_id), "tenant_id": str(tenant_id), "role": role}


# ─── Platform Admin: Delete Tenant (Cascade) ─────────

@router.delete("/{tenant_id}", status_code=204)
async def delete_tenant(
tenant_id: uuid.UUID,
current_user: User = Depends(require_role("platform_admin")),
db: AsyncSession = Depends(get_db),
):
"""Permanently delete a tenant and all associated data.

Platform admin only. Cannot delete the first (default) tenant.
Cascade-deletes all related records in dependency order within a single transaction.
"""
import logging
from sqlalchemy import delete as sa_delete, update as sa_update

from app.models.activity_log import AgentActivityLog, DailyTokenUsage
from app.models.agent import Agent, AgentPermission, AgentTemplate
from app.models.agent_credential import AgentCredential
from app.models.audit import AuditLog, ApprovalRequest, ChatMessage
from app.models.channel_config import ChannelConfig
from app.models.chat_session import ChatSession
from app.models.gateway_message import GatewayMessage
from app.models.identity import IdentityProvider, SSOScanSession
from app.models.invitation_code import InvitationCode
from app.models.llm import LLMModel
from app.models.notification import Notification
from app.models.org import OrgMember, OrgDepartment, AgentRelationship, AgentAgentRelationship
from app.models.participant import Participant
from app.models.plaza import PlazaPost, PlazaComment, PlazaLike
from app.models.published_page import PublishedPage
from app.models.schedule import AgentSchedule
from app.models.skill import Skill, SkillFile
from app.models.task import Task, TaskLog
from app.models.tenant_setting import TenantSetting
from app.models.tool import AgentTool
from app.models.trigger import AgentTrigger

logger = logging.getLogger(__name__)

# 1. Find the tenant
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")

# 2. Cannot delete the first tenant (acts as the default/primary tenant)
first_tenant = await db.execute(
select(Tenant).order_by(Tenant.created_at.asc()).limit(1)
)
first = first_tenant.scalar_one_or_none()
if first and first.id == tenant.id:
raise HTTPException(
status_code=400,
detail="Cannot delete the default tenant.",
)

logger.info("Deleting tenant %s (%s) and all associated data", tenant.id, tenant.name)

# 3. Collect agent_ids and user_ids for this tenant
agent_ids = [
row[0]
for row in (await db.execute(select(Agent.id).where(Agent.tenant_id == tenant_id))).all()
]
user_ids = [
row[0]
for row in (await db.execute(select(User.id).where(User.tenant_id == tenant_id))).all()
]

# 4. Delete tables that reference agents (via agent_id FK)
if agent_ids:
# Task logs before tasks (task_logs.task_id -> tasks.id -> agents.id)
task_ids = [
row[0]
for row in (await db.execute(select(Task.id).where(Task.agent_id.in_(agent_ids)))).all()
]
if task_ids:
await db.execute(sa_delete(TaskLog).where(TaskLog.task_id.in_(task_ids)))

await db.execute(sa_delete(AgentTrigger).where(AgentTrigger.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentSchedule).where(AgentSchedule.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentActivityLog).where(AgentActivityLog.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentCredential).where(AgentCredential.agent_id.in_(agent_ids)))
await db.execute(sa_delete(ChannelConfig).where(ChannelConfig.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentTool).where(AgentTool.agent_id.in_(agent_ids)))
await db.execute(sa_delete(Notification).where(Notification.agent_id.in_(agent_ids)))
await db.execute(sa_delete(Task).where(Task.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AuditLog).where(AuditLog.agent_id.in_(agent_ids)))
await db.execute(sa_delete(ApprovalRequest).where(ApprovalRequest.agent_id.in_(agent_ids)))
await db.execute(sa_delete(ChatMessage).where(ChatMessage.agent_id.in_(agent_ids)))
await db.execute(sa_delete(GatewayMessage).where(GatewayMessage.agent_id.in_(agent_ids)))
await db.execute(sa_delete(ChatSession).where(ChatSession.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentPermission).where(AgentPermission.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentAgentRelationship).where(AgentAgentRelationship.agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentAgentRelationship).where(AgentAgentRelationship.target_agent_id.in_(agent_ids)))
await db.execute(sa_delete(AgentRelationship).where(AgentRelationship.agent_id.in_(agent_ids)))
await db.execute(sa_delete(PublishedPage).where(PublishedPage.agent_id.in_(agent_ids)))

# Null out cross-tenant FK references (other tenants' records pointing to our agents)
await db.execute(
sa_update(ChatSession).where(ChatSession.peer_agent_id.in_(agent_ids)).values(peer_agent_id=None)
)
await db.execute(
sa_update(GatewayMessage).where(GatewayMessage.sender_agent_id.in_(agent_ids)).values(sender_agent_id=None)
)

# Delete Participant records for agents
await db.execute(
sa_delete(Participant).where(Participant.type == "agent", Participant.ref_id.in_(agent_ids))
)

# 5. Delete tables that reference users
if user_ids:
await db.execute(sa_delete(AgentTemplate).where(AgentTemplate.created_by.in_(user_ids)))

# Delete audit/notification rows that reference our users (with null or other-tenant agent_id)
await db.execute(sa_delete(AuditLog).where(AuditLog.user_id.in_(user_ids)))
await db.execute(sa_delete(Notification).where(Notification.user_id.in_(user_ids)))

# Null out cross-tenant user FK references
await db.execute(
sa_update(ApprovalRequest).where(ApprovalRequest.resolved_by.in_(user_ids)).values(resolved_by=None)
)
await db.execute(
sa_update(GatewayMessage).where(GatewayMessage.sender_user_id.in_(user_ids)).values(sender_user_id=None)
)

# Delete Participant records for users
await db.execute(
sa_delete(Participant).where(Participant.type == "user", Participant.ref_id.in_(user_ids))
)

# Plaza posts (and their comments/likes) authored by tenant users
plaza_post_ids = [
row[0]
for row in (await db.execute(
select(PlazaPost.id).where(PlazaPost.author_type == "user", PlazaPost.author_id.in_(user_ids))
)).all()
]
if plaza_post_ids:
await db.execute(sa_delete(PlazaComment).where(PlazaComment.post_id.in_(plaza_post_ids)))
await db.execute(sa_delete(PlazaLike).where(PlazaLike.post_id.in_(plaza_post_ids)))
await db.execute(sa_delete(PlazaPost).where(PlazaPost.id.in_(plaza_post_ids)))

# Plaza posts authored by tenant agents
if agent_ids:
agent_plaza_post_ids = [
row[0]
for row in (await db.execute(
select(PlazaPost.id).where(PlazaPost.author_type == "agent", PlazaPost.author_id.in_(agent_ids))
)).all()
]
if agent_plaza_post_ids:
await db.execute(sa_delete(PlazaComment).where(PlazaComment.post_id.in_(agent_plaza_post_ids)))
await db.execute(sa_delete(PlazaLike).where(PlazaLike.post_id.in_(agent_plaza_post_ids)))
await db.execute(sa_delete(PlazaPost).where(PlazaPost.id.in_(agent_plaza_post_ids)))

# 6. Delete tables with tenant_id (no agent/user dependency)
await db.execute(sa_delete(DailyTokenUsage).where(DailyTokenUsage.tenant_id == tenant_id))
await db.execute(sa_delete(OrgMember).where(OrgMember.tenant_id == tenant_id))
await db.execute(sa_delete(OrgDepartment).where(OrgDepartment.tenant_id == tenant_id))
await db.execute(sa_delete(InvitationCode).where(InvitationCode.tenant_id == tenant_id))
await db.execute(sa_delete(LLMModel).where(LLMModel.tenant_id == tenant_id))
await db.execute(sa_delete(TenantSetting).where(TenantSetting.tenant_id == tenant_id))
await db.execute(sa_delete(IdentityProvider).where(IdentityProvider.tenant_id == tenant_id))
await db.execute(sa_delete(SSOScanSession).where(SSOScanSession.tenant_id == tenant_id))

# SkillFiles before Skills (skill_files.skill_id -> skills.id)
skill_ids = [
row[0]
for row in (await db.execute(select(Skill.id).where(Skill.tenant_id == tenant_id))).all()
]
if skill_ids:
await db.execute(sa_delete(SkillFile).where(SkillFile.skill_id.in_(skill_ids)))
await db.execute(sa_delete(Skill).where(Skill.tenant_id == tenant_id))

# 7. Delete agents (after all agent-dependent tables)
await db.execute(sa_delete(Agent).where(Agent.tenant_id == tenant_id))

# 8. Delete users (after agents, since agents.creator_id -> users.id)
await db.execute(sa_delete(User).where(User.tenant_id == tenant_id))

# 9. Delete the tenant itself
await db.delete(tenant)
await db.flush()

logger.info("Tenant %s (%s) deleted successfully", tenant_id, tenant.name)

return None
101 changes: 100 additions & 1 deletion backend/app/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class UserOut(BaseModel):
username: str | None = None
email: str | None = None
display_name: str | None = None
primary_mobile: str | None = None
role: str
is_active: bool
# Quota fields
Expand Down Expand Up @@ -85,6 +86,7 @@ async def list_users(
"username": u.username or u.email or f"{u.registration_source or 'user'}_{str(u.id)[:8]}",
"email": u.email or "",
"display_name": u.display_name or u.username or "",
"primary_mobile": u.primary_mobile,
"role": u.role,
"is_active": u.is_active,
"quota_message_limit": u.quota_message_limit,
Expand Down Expand Up @@ -146,7 +148,8 @@ async def update_user_quota(

return UserOut(
id=user.id, username=user.username, email=user.email,
display_name=user.display_name, role=user.role, is_active=user.is_active,
display_name=user.display_name, primary_mobile=user.primary_mobile,
role=user.role, is_active=user.is_active,
quota_message_limit=user.quota_message_limit,
quota_message_period=user.quota_message_period,
quota_messages_used=user.quota_messages_used,
Expand Down Expand Up @@ -224,3 +227,99 @@ async def update_user_role(
target_user.role = data.role
await db.commit()
return {"status": "ok", "user_id": str(user_id), "role": data.role}


# ─── Profile Management ───────────────────────────────

class UserProfileUpdate(BaseModel):
display_name: str | None = None
email: str | None = None
primary_mobile: str | None = None
is_active: bool | None = None
new_password: str | None = None


@router.patch("/{user_id}/profile", response_model=UserOut)
async def update_user_profile(
user_id: uuid.UUID,
data: UserProfileUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update a user's basic profile information.

Permissions:
- platform_admin: can edit any user.
- org_admin: can only edit users within the same tenant.
- Cannot edit platform_admin users (unless caller is platform_admin).
"""
if current_user.role not in ("platform_admin", "org_admin"):
raise HTTPException(status_code=403, detail="Admin access required")

# Fetch target user
result = await db.execute(select(User).where(User.id == user_id))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")

# org_admin can only edit users in the same tenant
if current_user.role == "org_admin":
if target.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=403, detail="Cannot modify users outside your organization")
if target.role == "platform_admin":
raise HTTPException(status_code=403, detail="Cannot modify platform admin users")

# Update fields
if data.display_name is not None:
if not data.display_name.strip():
raise HTTPException(status_code=400, detail="Display name cannot be empty")
target.display_name = data.display_name.strip()
if data.email is not None:
email_val = data.email.strip().lower()
if email_val:
# Check email uniqueness (exclude self)
existing = await db.execute(
select(User).where(User.email == email_val, User.id != user_id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already in use by another user")
target.email = email_val
if data.primary_mobile is not None:
target.primary_mobile = data.primary_mobile.strip() or None
if data.is_active is not None:
target.is_active = data.is_active
if data.new_password is not None and data.new_password.strip():
from app.core.security import hash_password
if len(data.new_password.strip()) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
target.password_hash = hash_password(data.new_password.strip())

await db.commit()
await db.refresh(target)

# Count agents for response
count_result = await db.execute(
select(func.count()).select_from(Agent).where(
Agent.creator_id == target.id,
Agent.is_expired == False,
)
)
agents_count = count_result.scalar() or 0

return UserOut(
id=target.id,
username=target.username,
email=target.email,
display_name=target.display_name,
primary_mobile=target.primary_mobile,
role=target.role,
is_active=target.is_active,
quota_message_limit=target.quota_message_limit,
quota_message_period=target.quota_message_period,
quota_messages_used=target.quota_messages_used,
quota_max_agents=target.quota_max_agents,
quota_agent_ttl_hours=target.quota_agent_ttl_hours,
agents_count=agents_count,
created_at=target.created_at.isoformat() if target.created_at else None,
source=target.registration_source or "registered",
)
Loading