| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- 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
|