1215 lines
46 KiB
Python
1215 lines
46 KiB
Python
"""
|
|
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"
|
|
)
|
|
|
|
|
|
|