import hashlib from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import delete, select from sqlalchemy.orm import Session, selectinload from app.api import deps from app.api.utils import issue_reset_token from app.core.config import settings from app.core.email import EmailSender, get_email_sender from app.core.security import get_password_hash, is_reset_rate_limited, log_reset_request from app.models import GlobalRole, PasswordResetToken, User, UserOrganization from app.schemas.requests import ( ForgotPasswordRequest, SetPasswordWithTokenRequest, UserCreateRequest, UserProfileUpdateRequest, UserUpdatePasswordRequest, ) from app.schemas.responses import UserResponse router = APIRouter() @router.get("", response_model=list[UserResponse]) async def list_users( current_user: User = Depends(deps.get_current_user), session: Session = Depends(deps.get_session), ): """Returns users. super_admin sees everyone; anyone else sees only users who share at least one organization with them -- enough to find teammates when adding commission members or org roles, without leaking the full cross-organization user directory.""" if current_user.global_role == GlobalRole.SUPER_ADMIN: return ( session.execute(select(User).options(selectinload(User.organizations))).scalars().all() ) org_ids = [m.organization_id for m in current_user.organizations] if not org_ids: return [current_user] result = session.execute( select(User) .join(UserOrganization, UserOrganization.user_id == User.id) .where(UserOrganization.organization_id.in_(org_ids)) .options(selectinload(User.organizations)) .distinct() ) return result.scalars().all() @router.get("/me", response_model=UserResponse) async def read_current_user( current_user: User = Depends(deps.get_current_user), ): """Get current user""" return current_user @router.post("/me", response_model=UserResponse) async def update_current_user_profile( payload: UserProfileUpdateRequest, current_user: User = Depends(deps.get_current_user), session: Session = Depends(deps.get_session), ): """Update the caller's own display name and/or contact phone number. This is the only place phone_number is ever written -- commission contact info (Commission.contacts) is derived from it, not stored separately.""" if payload.name is not None: current_user.name = payload.name if payload.phone_number is not None: current_user.phone_number = payload.phone_number session.add(current_user) session.commit() session.refresh(current_user) return current_user @router.delete("/me", status_code=204) async def delete_current_user( current_user: User = Depends(deps.get_current_user), session: Session = Depends(deps.get_session), ): """Delete current user""" session.execute(delete(User).where(User.id == current_user.id)) session.commit() @router.post("/update-password", response_model=UserResponse) async def reset_current_user_password( user_update_password: UserUpdatePasswordRequest, session: Session = Depends(deps.get_session), current_user: User = Depends(deps.get_current_user), ): """Update current user password""" current_user.hashed_password = get_password_hash(user_update_password.password) session.add(current_user) session.commit() return current_user @router.post("/register", response_model=UserResponse) async def register_new_user( new_user: UserCreateRequest, session: Session = Depends(deps.get_session), ): """Create new user. Does not attach the user to any organization -- a super_admin must add them via POST /organizations/{id}/members afterward.""" result = session.execute(select(User).where(User.email == new_user.email)) if result.scalars().first() is not None: raise HTTPException(status_code=400, detail="Cannot use this email address") user = User( email=new_user.email, name=new_user.name, hashed_password=get_password_hash(new_user.password), ) session.add(user) session.commit() session.refresh(user) return user @router.post("/forgot-password", status_code=204) async def forgot_password( payload: ForgotPasswordRequest, session: Session = Depends(deps.get_session), email_sender: EmailSender = Depends(get_email_sender), ): """Always returns 204 regardless of whether the email exists -- prevents account enumeration via response-timing/content differences.""" log_reset_request(session, payload.email) if is_reset_rate_limited(session, payload.email): return user = session.scalar(select(User).where(User.email == payload.email)) if user is not None: await issue_reset_token(session, user, "reset", email_sender, settings.FRONT_END_URL) @router.post("/set-password", response_model=UserResponse) async def set_password_with_token( payload: SetPasswordWithTokenRequest, session: Session = Depends(deps.get_session), ): """Consumes a reset or invite token, sets the password, marks it used.""" token_hash = hashlib.sha256(payload.token.encode()).hexdigest() reset_token = session.scalar( select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash) ) if ( reset_token is None or reset_token.used_at is not None or reset_token.expires_at < datetime.now(UTC) ): raise HTTPException(status_code=400, detail="Invalid or expired link") user = session.get(User, reset_token.user_id) user.hashed_password = get_password_hash(payload.password) user.must_change_password = False reset_token.used_at = datetime.now(UTC) session.commit() session.refresh(user) return user