Files
replypier/backend/app/api/users.py
T

1214 lines
46 KiB
Python
Raw Normal View History

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status, Response, Request, Body, File, UploadFile
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.security import HTTPBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import List, Optional
import json
import os
from urllib.parse import quote
from sqlalchemy.sql import func
from uuid import UUID
from app.database import get_db
from app.models.user import User
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.models.schemas.user import AdminPasswordReset, TeammateResponse, UserCreate, UserStatusUpdate, UserUpdate, UserResponse, TokenResponse
from datetime import datetime, timezone
from app.core.security import validate_password_strength
from app.core.auth import INBOX_PERMISSIONS, get_current_user, request_token, require_any_permission, require_permissions
from app.services.auth_sessions import (
REFRESH, issue_tokens, live_payload, revoke_session, revoke_user_sessions, session_id_of,
)
from app.core.logger import get_logger
from app.repositories.user import UserRepository
from app.repositories.fcm_token import FCMTokenRepository
from pydantic import BaseModel
from app.models.role import Role
from app.services.chat_scope_roles import resolve_role
from app.core.s3 import get_s3_signed_url, sign_s3_url, upload_file_to_s3, delete_file_from_s3
from app.core.config import settings
from app.services.file_storage import local_upload_path
from app.repositories.shopify_shop_repository import ShopifyShopRepository
from app.models.schemas.shopify.shopify_shop import ShopifyShopUpdate
from app.core.cors import update_cors_middleware
from app.core.application import app
from app.core.params import MAX_DB_INT
# Try to import enterprise modules
try:
from app.enterprise.repositories.subscription import SubscriptionRepository
from app.enterprise.repositories.plan import PlanRepository
from app.enterprise.services.feature_access import require_accessible_subscription
HAS_ENTERPRISE = True
except ImportError:
HAS_ENTERPRISE = False
# Disposable-address rejection lives in the enterprise module: the hosted signup
# flow is what attracts throwaway signups, and the community edition has no
# reason to carry an 8k-domain blocklist. Absent, every address is accepted.
try:
from app.enterprise.services.email_validation import ensure_not_disposable
HAS_EMAIL_VALIDATION = True
except ImportError:
HAS_EMAIL_VALIDATION = False
logger = get_logger(__name__)
router = APIRouter(
tags=["users"]
)
security = HTTPBearer()
UPLOAD_DIR = "uploads/user"
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
def _require_role_in_org(db, role_id, org_id) -> Optional[Role]:
"""A user may only be given a role defined in their own organization.
Role ids are sequential integers, so without this an admin could bind a
user to another tenant's role — inheriting that role's permissions and
breaking the other org's "is this role in use" accounting.
"""
if role_id is None:
return None
role = db.query(Role).filter(Role.id == role_id).first()
if not role or role.organization_id != org_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
return role
def get_file_extension(filename: str) -> str:
return os.path.splitext(filename)[1].lower()
async def save_upload_file(file: UploadFile, org_id: str, user_id: str) -> str:
"""Save uploaded file and return the file path"""
# Generate unique filename
file_extension = get_file_extension(file.filename)
filename = f"profile{file_extension}"
if settings.S3_FILE_STORAGE:
folder = f"users/{org_id}/{user_id}"
content = await file.read()
return await upload_file_to_s3(content, folder, filename, content_type=file.content_type)
else:
# Local storage
# Create upload directory if it doesn't exist
user_upload_dir = os.path.join(UPLOAD_DIR, org_id, user_id)
os.makedirs(user_upload_dir, exist_ok=True)
file_path = os.path.join(user_upload_dir, filename)
# Save file
with open(file_path, "wb") as buffer:
content = await file.read()
buffer.write(content)
# Prefixed to match the {API_V1_STR}/uploads static mount, as store_upload does.
return f"{settings.API_V1_STR}/uploads/user/{org_id}/{user_id}/{filename}"
@router.post("", response_model=UserResponse)
async def create_user(
user_data: UserCreate,
current_user: User = Depends(require_permissions("manage_users")),
db: Session = Depends(get_db)
):
"""Create a new user"""
try:
if HAS_EMAIL_VALIDATION:
ensure_not_disposable(user_data.email)
user_repo = UserRepository(db)
# Check if email already exists
if user_repo.get_user_by_email(user_data.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Check enterprise subscription limits if enterprise module is available
if HAS_ENTERPRISE:
# Accessible = active/trial/past-due-in-period OR cancelled-but-
# still-in-paid-period; raises 403 when the org has no plan.
subscription = require_accessible_subscription(db, current_user.organization_id)
# Check if max_users feature exists in plan
plan_repo = PlanRepository(db)
if plan_repo.check_feature_availability(subscription.plan_id, 'max_users'):
# Get current active users count
active_users = user_repo.get_active_users_count(str(current_user.organization_id))
# Check against subscription quantity (user seats)
if subscription.quantity is not None and active_users >= subscription.quantity:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Maximum number of users ({subscription.quantity}) reached for your plan. Please upgrade your plan to add more users."
)
# The role must belong to the caller's own organization.
role = _require_role_in_org(db, user_data.role_id, current_user.organization_id)
# The chat-scope toggles may point at a different role than the one
# picked in the dropdown. Returns the same role when they match it.
role = resolve_role(
db, current_user, role,
user_data.see_all_ai_chats, user_data.see_all_org_chats,
)
# Hash the password
hashed_password = User.get_password_hash(user_data.password)
# Create user with organization from current user
new_user = user_repo.create_user(
email=user_data.email,
full_name=user_data.full_name,
hashed_password=hashed_password,
organization_id=current_user.organization_id,
is_active=user_data.is_active,
role_id=role.id
)
return new_user.to_dict()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating user: {str(e)}")
if "duplicate key value violates unique constraint" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("", response_model=List[UserResponse])
async def list_users(
skip: int = Query(0, ge=0, le=MAX_DB_INT),
limit: int = Query(100, ge=1, le=1000),
current_user: User = Depends(require_permissions("manage_users")),
db: Session = Depends(get_db)
):
"""List all users in the organization"""
try:
user_repo = UserRepository(db)
users = user_repo.get_users_by_organization(current_user.organization_id)
# profile_pic is signed by UserResponse on serialization — assigning the
# signed value onto the ORM object would persist it on the next flush.
return users
except Exception as e:
logger.error(f"Error listing users: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
# Default concurrent-chat capacity for an agent (no per-user column yet).
DEFAULT_AGENT_CAPACITY = 5
class TeamAgentStats(BaseModel):
id: str
full_name: str
email: str
profile_pic: Optional[str] = None
is_online: bool = False
last_seen: Optional[datetime] = None
is_active: bool = True
role: Optional[str] = None
is_admin: bool = False
groups: List[str] = []
active_chats: int = 0
resolved_chats: int = 0
capacity: int = DEFAULT_AGENT_CAPACITY
class TeamKpis(BaseModel):
team_size: int = 0
admins: int = 0
agents: int = 0
online_now: int = 0
active_chats: int = 0
total_capacity: int = 0
waiting_handoff: int = 0
oldest_wait_minutes: int = 0
class TeamOverviewResponse(BaseModel):
kpis: TeamKpis
agents: List[TeamAgentStats]
@router.get("/team-overview", response_model=TeamOverviewResponse)
async def get_team_overview(
current_user: User = Depends(require_permissions("manage_users")),
db: Session = Depends(get_db)
):
"""Aggregated Human-Agents dashboard: per-agent load/resolved counts + org KPIs."""
try:
org_id = current_user.organization_id
user_repo = UserRepository(db)
users = user_repo.get_users_by_organization(org_id)
# Per-agent active (OPEN, assigned to a human) chat counts
active_rows = (
db.query(SessionToAgent.user_id, func.count(SessionToAgent.session_id))
.filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.status == SessionStatus.OPEN,
SessionToAgent.user_id.isnot(None),
)
.group_by(SessionToAgent.user_id)
.all()
)
active_counts = {str(uid): cnt for uid, cnt in active_rows}
# Per-agent resolved (CLOSED) chat counts
resolved_rows = (
db.query(SessionToAgent.user_id, func.count(SessionToAgent.session_id))
.filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.status == SessionStatus.CLOSED,
SessionToAgent.user_id.isnot(None),
)
.group_by(SessionToAgent.user_id)
.all()
)
resolved_counts = {str(uid): cnt for uid, cnt in resolved_rows}
# Org-wide waiting handoffs (transferred, not yet picked up by a human)
waiting_handoff = (
db.query(func.count(SessionToAgent.session_id))
.filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.status == SessionStatus.TRANSFERRED,
SessionToAgent.user_id.is_(None),
)
.scalar()
or 0
)
oldest_wait = (
db.query(func.min(SessionToAgent.assigned_at))
.filter(
SessionToAgent.organization_id == org_id,
SessionToAgent.status == SessionStatus.TRANSFERRED,
SessionToAgent.user_id.is_(None),
)
.scalar()
)
oldest_wait_minutes = 0
if oldest_wait is not None:
if oldest_wait.tzinfo is None:
oldest_wait = oldest_wait.replace(tzinfo=timezone.utc)
oldest_wait_minutes = max(
0, int((datetime.now(timezone.utc) - oldest_wait).total_seconds() // 60)
)
agents: List[TeamAgentStats] = []
admins = 0
online_now = 0
total_active = 0
total_capacity = 0
for user in users:
uid = str(user.id)
role_name = user.role.name if user.role else None
perms = {p.name for p in (user.role.permissions or [])} if user.role else set()
is_admin = bool(perms & {"manage_organization", "super_admin"}) or (role_name == "Admin")
if is_admin:
admins += 1
if user.is_online:
online_now += 1
active = active_counts.get(uid, 0)
total_active += active
total_capacity += DEFAULT_AGENT_CAPACITY
profile_pic = user.profile_pic
if settings.S3_FILE_STORAGE and profile_pic:
try:
profile_pic = await get_s3_signed_url(profile_pic)
except Exception:
pass
agents.append(TeamAgentStats(
id=uid,
full_name=user.full_name,
email=user.email,
profile_pic=profile_pic,
is_online=bool(user.is_online),
last_seen=user.last_seen,
is_active=bool(user.is_active),
role=role_name,
is_admin=is_admin,
groups=[g.name for g in (user.groups or [])],
active_chats=active,
resolved_chats=resolved_counts.get(uid, 0),
capacity=DEFAULT_AGENT_CAPACITY,
))
kpis = TeamKpis(
team_size=len(users),
admins=admins,
agents=len(users) - admins,
online_now=online_now,
active_chats=total_active,
total_capacity=total_capacity,
waiting_handoff=int(waiting_handoff),
oldest_wait_minutes=oldest_wait_minutes,
)
return TeamOverviewResponse(kpis=kpis, agents=agents)
except Exception as e:
logger.error(f"Error building team overview: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.get("/teammates", response_model=List[TeammateResponse])
async def list_teammates(
current_user: User = Depends(require_any_permission(*INBOX_PERMISSIONS)),
db: Session = Depends(get_db)
):
"""The colleagues an inbox user can hand a conversation to.
Declared above GET /{user_id}: below it, "teammates" parses as a user id
and the request lands on the manage_users route — a 403 for exactly the
agents this exists for.
Deliberately not a relaxation of GET /users, which returns each teammate's
role and its full permission list. Reassigning a chat needs a name and a
face, so that is all this returns.
"""
users = UserRepository(db).get_users_by_organization(current_user.organization_id)
return [user for user in users if user.is_active]
@router.get("/me/avatar")
async def get_my_avatar(current_user: User = Depends(get_current_user)):
"""Redirect to the caller's profile picture, signed at request time.
The dashboard used to render the URL held in the cached `user_info` blob.
That blob is written at login and never rewritten, but the URL inside it is
an S3 presigned link good for S3_PRESIGN_EXPIRY_SECONDS — an hour by
default. Every session outlasted its own avatar, and the only way to get a
working one back was to log out and in again.
A stable path fixes that for good: the browser asks us, and whatever it
gets handed is freshly signed. no-store because the *redirect* must not be
cached — the object it points at still can be.
Declared above GET /{user_id}, or "me" parses as a user id.
"""
if not current_user.profile_pic:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No profile picture")
location = sign_s3_url(current_user.profile_pic)
if not location.startswith(("http://", "https://")):
# Local storage is served under the API prefix, which is what
# save_upload_file now stores. Rows written before that normalisation
# carry a bare "/uploads/..." and would 404, so prefix those too —
# same tolerance local_upload_path applies on the way back down.
path = location.lstrip("/")
if path.startswith("uploads/"):
path = f"{settings.API_V1_STR.strip('/')}/{path}"
location = f"/{path}"
return RedirectResponse(
url=location,
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
headers={"Cache-Control": "no-store"},
)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: UUID,
current_user: User = Depends(require_permissions("manage_users")),
db: Session = Depends(get_db)
):
"""Get user by ID"""
user_repo = UserRepository(db)
user = user_repo.get_user(user_id)
if not user or user.organization_id != current_user.organization_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user.to_dict()
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
current_user: User = Depends(require_permissions("manage_users")),
db: Session = Depends(get_db)
):
"""Delete a user"""
try:
user_repo = UserRepository(db)
user = user_repo.get_user(user_id)
if not user or user.organization_id != current_user.organization_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user_repo.delete_user(user_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete user: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete user"
)
@router.post("/login", response_model=TokenResponse)
async def login(
response: Response,
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""Authenticate user and set cookies"""
try:
# Verify credentials
user = db.query(User).filter(
User.email == form_data.username,
User.is_active == True
).first()
if not user or not user.verify_password(form_data.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Update online status and open a session for this sign-in
user.is_online = True
user.last_seen = func.now()
access_token, refresh_token = issue_tokens(db, user.id, user.organization_id)
db.commit()
# Get role info
role = db.query(Role).filter(Role.id == user.role_id).first()
# Set secure cookies
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=True,
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
max_age=1800 # 30 minutes
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
max_age=604800 # 7 days
)
# Set session data with role information
user_info = json.dumps({
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"organization_id": str(user.organization_id),
"role": role.to_dict() if role else None
}, default=str)
response.set_cookie(
key="user_info",
value=quote(user_info), # URL encode the JSON string
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
secure=True, # Required when samesite="none"
max_age=604800 # 7 days
)
# Handle Shopify shop update if shop_id is provided in query params
shop_id = request.query_params.get('shop_id')
if shop_id:
try:
shop_repository = ShopifyShopRepository(db)
db_shop = shop_repository.get_shop(shop_id)
if db_shop:
# Update shop with user's organization_id
shop_update = ShopifyShopUpdate(
organization_id=str(user.organization_id)
)
shop_repository.update_shop(db_shop.id, shop_update)
# Update organization domain with shop domain
try:
from app.repositories.organization import OrganizationRepository
org_repo = OrganizationRepository(db)
organization = org_repo.get_organization(str(user.organization_id))
if organization and db_shop.shop_domain:
# Only update if domain is different
if organization.domain != db_shop.shop_domain:
# Check if domain is already used by another organization
existing_org = org_repo.get_organization_by_domain(db_shop.shop_domain)
if not existing_org or str(existing_org.id) == str(organization.id):
# Update organization domain to shop domain
organization.domain = db_shop.shop_domain
db.add(organization)
logger.info(f"Updated organization {user.organization_id} domain to {db_shop.shop_domain}")
else:
logger.warning(f"Domain {db_shop.shop_domain} is already used by organization {existing_org.id}, skipping update")
except Exception as domain_error:
logger.error(f"Error updating organization domain during login: {str(domain_error)}")
# Don't fail login if domain update fails
db.commit()
# Update CORS middleware to include new domain
try:
update_cors_middleware(app)
logger.info("Updated CORS middleware after linking shop during login")
except Exception as cors_error:
logger.error(f"Error updating CORS middleware: {str(cors_error)}")
# Don't fail login if CORS update fails
logger.info(f"Updated shop {db_shop.shop_domain} with organization_id {user.organization_id} after login")
else:
logger.warning(f"Shop {shop_id} not found during login")
except Exception as e:
logger.error(f"Error updating shopify shop during login: {str(e)}")
# Don't fail login if shop update fails
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"organization_id": user.organization_id,
"profile_pic": await get_s3_signed_url(user.profile_pic) if settings.S3_FILE_STORAGE and user.profile_pic else user.profile_pic,
"is_online": user.is_online,
"last_seen": user.last_seen,
"is_active": user.is_active,
"role": role.to_dict() if role else None
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Login failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Login failed. Please try again later."
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
response: Response,
request: Request, # Add this to access cookies
db: Session = Depends(get_db)
):
"""Get new access token using refresh token from cookie"""
try:
# Get refresh token from cookie
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token missing",
headers={"WWW-Authenticate": "Bearer"},
)
payload = live_payload(db, refresh_token, REFRESH)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
org_id = payload.get("org")
if not user_id or not org_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
# Convert string UUID to UUID object
try:
user_id = UUID(user_id)
org_id = UUID(org_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload format",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify user still exists and is active
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
# Update last seen, and re-mint tokens for the same session (a legacy
# token without one gets a new session)
user.last_seen = func.now()
access_token, refresh_token = issue_tokens(db, user_id, org_id, sid=payload.get("sid"))
db.commit()
role = db.query(Role).filter(Role.id == user.role_id).first()
# Set secure cookies
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=True,
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
max_age=1800 # 30 minutes
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
max_age=604800 # 7 days
)
# Set session data
user_info = json.dumps({
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"organization_id": str(user.organization_id),
"role": role.to_dict() if role else None
}, default=str)
response.set_cookie(
key="user_info",
value=quote(user_info), # URL encode the JSON string
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
secure=True, # Required when samesite="none"
max_age=604800 # 7 days
)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"organization_id": user.organization_id,
"profile_pic": await get_s3_signed_url(user.profile_pic) if settings.S3_FILE_STORAGE and user.profile_pic else user.profile_pic,
"is_online": user.is_online,
"last_seen": user.last_seen,
"is_active": user.is_active,
"role": role.to_dict() if role else None
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Token refresh failed: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Token refresh failed. Please try again later."
)
@router.post("/logout")
async def logout(
response: Response,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Logout user, end the session and clear cookies"""
# End the session server-side, so a copy of either token stops working
for token in (request_token(request), request.cookies.get("refresh_token")):
sid = session_id_of(token)
if sid:
revoke_session(db, sid)
# Update online status (commits the revocation too)
user_repo = UserRepository(db)
user_repo.update_user(current_user.id, is_online=False, last_seen=func.now())
# Clear cookies
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
response.delete_cookie("user_info")
return {"message": "Successfully logged out"}
class FCMTokenUpdate(BaseModel):
token: str
@router.post("/token/fcm-token")
async def register_fcm_token(
token_data: FCMTokenUpdate = Body(...),
current_user: User = Depends(get_current_user),
db=Depends(get_db)
):
"""Register this browser's web-push token for the signed-in user."""
try:
token_repo = FCMTokenRepository(db)
success = token_repo.register(current_user.id, token_data.token)
if success:
return {"message": "FCM token registered successfully"}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to register FCM token"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error registering FCM token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.delete("/token/fcm-token")
async def unregister_fcm_token(
token_data: FCMTokenUpdate = Body(...),
current_user: User = Depends(get_current_user),
db=Depends(get_db)
):
"""Drop this browser's token on logout.
The token is required: clearing every token for the account (what this used
to do) meant signing out on a laptop silently killed push on the user's
phone. A device that can no longer produce its token is cleaned up instead
by the dead-token pruning in send_fcm_notification.
"""
try:
token_repo = FCMTokenRepository(db)
token_repo.remove(current_user.id, token_data.token)
# Absent rows are not an error: logging out twice, or after the token
# was already pruned, should still succeed.
return {"message": "FCM token cleared successfully"}
except Exception as e:
logger.error(f"Error clearing FCM token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.patch("/me", response_model=UserResponse)
async def update_profile(
data: UserUpdate,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update current user's profile"""
try:
user_repo = UserRepository(db)
# Remove role_id from update data to prevent role modification
if hasattr(data, 'role_id'):
delattr(data, 'role_id')
# If updating email, check it's a real mailbox and not already taken.
# Same bounce risk as signup, and otherwise a trivial way around that gate.
if data.email and data.email != current_user.email:
if HAS_EMAIL_VALIDATION:
ensure_not_disposable(data.email)
existing_user = user_repo.get_user_by_email(data.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
fields = data.dict(exclude_unset=True)
# `password` and `current_password` are request-only fields; the column
# is `hashed_password`. Passing `password` through wrote a stray
# attribute onto the ORM object that was never persisted, so changing
# your own password quietly did nothing while returning 200.
new_password = fields.pop('password', None)
current_password = fields.pop('current_password', None)
if new_password:
if not current_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is required"
)
if not current_user.verify_password(current_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
# Same bar an admin-set password has to clear — otherwise the
# policy is one self-service change away from being bypassed.
try:
validate_password_strength(new_password)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
fields['hashed_password'] = User.get_password_hash(new_password)
# Sign out everywhere else; the session making the change stays.
revoke_user_sessions(
db, current_user.id, keep_sid=session_id_of(request_token(request))
)
updated_user = user_repo.update_user(
current_user.id,
**fields
)
# Generate signed URL if using S3 and user has a profile picture
if settings.S3_FILE_STORAGE and updated_user.profile_pic:
signed_url = await get_s3_signed_url(updated_user.profile_pic)
# Create a response with both the user data and the signed URL
user_dict = updated_user.to_dict()
user_dict["profile_pic"] = signed_url
return user_dict
return updated_user
except HTTPException:
raise
except Exception as e:
logger.error(f"Profile update failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update profile"
)
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
user_data: UserUpdate,
current_user: User = Depends(require_permissions("manage_users")),
db: Session = Depends(get_db)
):
"""Update a user"""
user_repo = UserRepository(db)
user = user_repo.get_user(user_id)
if not user or user.organization_id != current_user.organization_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# A supplied role must belong to the caller's org, not another tenant's.
role = _require_role_in_org(db, user_data.role_id, current_user.organization_id)
# Check if updating email and if it's already taken
if user_data.email and user_data.email != user.email:
if HAS_EMAIL_VALIDATION:
ensure_not_disposable(user_data.email)
existing_user = user_repo.get_user_by_email(user_data.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
if HAS_ENTERPRISE and hasattr(user_data, 'is_active') and user_data.is_active != user.is_active:
# Accessible sub (incl. cancelled-but-still-in-paid-period), or None.
subscription = SubscriptionRepository(db).get_active_subscription(str(user.organization_id))
# When activating a user, enforce the plan's seat limit. A returned
# subscription is always currently accessible, so no is_active recheck.
if subscription and user_data.is_active:
plan_repo = PlanRepository(db)
if plan_repo.check_feature_availability(subscription.plan_id, 'max_users'):
active_users = user_repo.get_active_users_count(str(user.organization_id))
if subscription.quantity is not None and active_users >= subscription.quantity:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Cannot activate user: Maximum number of users ({subscription.quantity}) reached for your plan. Please upgrade your plan to add more users."
)
try:
fields = user_data.dict(exclude_unset=True)
# A password sent here used to be written straight onto the ORM object as
# a `password` attribute the User model does not have — silently doing
# nothing. Resetting someone else's password has its own endpoint below.
fields.pop('password', None)
fields.pop('current_password', None)
# The toggles describe a chat scope, not a column. They resolve against
# whichever role the user is ending up with — the one just picked, or
# the one they already had when the dropdown was left alone.
see_all_ai_chats = fields.pop('see_all_ai_chats', None)
see_all_org_chats = fields.pop('see_all_org_chats', None)
base_role = role or user.role
if base_role is not None:
# Assigned unconditionally, not only when it differs from the role
# in the payload: the toggles can resolve back to a role the person
# is already on, and letting the dropdown's id win there would
# silently narrow them.
fields['role_id'] = resolve_role(
db, current_user, base_role, see_all_ai_chats, see_all_org_chats
).id
if fields.get('is_active') is False:
# Already refused at every auth check; ending the sessions too
# means reactivating the user does not bring them back.
revoke_user_sessions(db, user.id)
updated_user = user_repo.update_user(user_id, **fields)
# Generate signed URL if using S3 and user has a profile picture
if settings.S3_FILE_STORAGE and updated_user.profile_pic:
signed_url = await get_s3_signed_url(updated_user.profile_pic)
# Create a response with both the user data and the signed URL
user_dict = updated_user.to_dict()
user_dict["profile_pic"] = signed_url
return user_dict
return updated_user
except Exception as e:
logger.error(f"Failed to update user: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user"
)
@router.post("/{user_id}/reset-password")
async def reset_user_password(
user_id: str,
data: AdminPasswordReset,
current_user: User = Depends(require_permissions("manage_users")),
db: Session = Depends(get_db)
):
"""Set a new password for another user in the caller's organization.
For the common case of an agent locked out of their account: the admin sets
a password and hands it over, rather than the agent going through the
email OTP flow (which the community edition does not ship).
"""
user_repo = UserRepository(db)
user = user_repo.get_user(user_id)
if not user or user.organization_id != current_user.organization_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# Changing your own password still goes through /users/me, which proves you
# know the current one. Allowing it here would let anyone holding a stolen
# admin session lock the real admin out without ever learning their password.
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Use your profile settings to change your own password"
)
try:
# Whoever holds the old password may already be signed in; end that.
revoke_user_sessions(db, user.id)
user_repo.update_user(user_id, hashed_password=User.get_password_hash(data.new_password))
except Exception as e:
logger.error(f"Failed to reset password for user {user_id}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)
logger.info(f"User {current_user.id} reset the password of user {user_id}")
return {"message": "Password reset successfully"}
@router.post("/{user_id}/status")
async def update_user_status(
user_id: str,
status_data: UserStatusUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update user's online status"""
try:
user_repo = UserRepository(db)
user = user_repo.get_user(user_id)
if not user or user.id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can only update own status"
)
user_repo.update_user(
user_id,
is_online=status_data.is_online,
last_seen=func.now()
)
# Get updated user to return current last_seen
updated_user = user_repo.get_user(user_id)
return {
"message": "Status updated successfully",
"is_online": updated_user.is_online,
"last_seen": updated_user.last_seen
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Status update failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update status"
)
@router.post("/me/profile-pic", response_model=UserResponse)
async def upload_profile_pic(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload user profile picture"""
try:
# Validate file extension
file_ext = get_file_extension(file.filename)
if file_ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}"
)
# Validate file size
file_size = len(await file.read())
await file.seek(0) # Reset file pointer
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size too large. Maximum size: {MAX_FILE_SIZE/1024/1024}MB"
)
# Delete old profile picture if it exists
if current_user.profile_pic:
if settings.S3_FILE_STORAGE:
await delete_file_from_s3(current_user.profile_pic)
else:
old_photo_path = local_upload_path(current_user.profile_pic)
if os.path.exists(old_photo_path):
os.remove(old_photo_path)
# Save file and update user
file_path = await save_upload_file(
file,
str(current_user.organization_id),
str(current_user.id)
)
user_repo = UserRepository(db)
updated_user = user_repo.update_user(
current_user.id,
profile_pic=file_path
)
# Generate signed URL if using S3
if settings.S3_FILE_STORAGE and updated_user.profile_pic:
signed_url = await get_s3_signed_url(updated_user.profile_pic)
# Create a response with both the user data and the signed URL
user_dict = updated_user.to_dict()
user_dict["profile_pic"] = signed_url
return user_dict
return updated_user
except HTTPException:
raise
except Exception as e:
logger.error(f"Profile picture upload failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upload profile picture"
)
@router.delete("/me/profile-pic", response_model=UserResponse)
async def delete_profile_pic(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete user profile picture"""
try:
if current_user.profile_pic:
if settings.S3_FILE_STORAGE:
await delete_file_from_s3(current_user.profile_pic)
else:
old_photo_path = local_upload_path(current_user.profile_pic)
if os.path.exists(old_photo_path):
os.remove(old_photo_path)
user_repo = UserRepository(db)
updated_user = user_repo.update_user(
current_user.id,
profile_pic=None
)
# Return user data with profile_pic_url set to None for consistency
if settings.S3_FILE_STORAGE:
user_dict = updated_user.to_dict()
user_dict["profile_pic"] = None
return user_dict
return updated_user
except Exception as e:
logger.error(f"Profile picture deletion failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete profile picture"
)