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: 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
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",
)