users.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import hashlib
  2. from datetime import UTC, datetime
  3. from fastapi import APIRouter, Depends, HTTPException
  4. from sqlalchemy import delete, select
  5. from sqlalchemy.orm import Session, selectinload
  6. from app.api import deps
  7. from app.api.utils import issue_reset_token
  8. from app.core.config import settings
  9. from app.core.email import EmailSender, get_email_sender
  10. from app.core.security import get_password_hash, is_reset_rate_limited, log_reset_request
  11. from app.models import GlobalRole, PasswordResetToken, User, UserOrganization
  12. from app.schemas.requests import (
  13. ForgotPasswordRequest,
  14. SetPasswordWithTokenRequest,
  15. UserCreateRequest,
  16. UserProfileUpdateRequest,
  17. UserUpdatePasswordRequest,
  18. )
  19. from app.schemas.responses import UserResponse
  20. router = APIRouter()
  21. @router.get("", response_model=list[UserResponse])
  22. async def list_users(
  23. current_user: User = Depends(deps.get_current_user),
  24. session: Session = Depends(deps.get_session),
  25. ):
  26. """Returns users. super_admin sees everyone; anyone else sees only users
  27. who share at least one organization with them -- enough to find teammates
  28. when adding commission members or org roles, without leaking the full
  29. cross-organization user directory."""
  30. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  31. return (
  32. session.execute(select(User).options(selectinload(User.organizations))).scalars().all()
  33. )
  34. org_ids = [m.organization_id for m in current_user.organizations]
  35. if not org_ids:
  36. return [current_user]
  37. result = session.execute(
  38. select(User)
  39. .join(UserOrganization, UserOrganization.user_id == User.id)
  40. .where(UserOrganization.organization_id.in_(org_ids))
  41. .options(selectinload(User.organizations))
  42. .distinct()
  43. )
  44. return result.scalars().all()
  45. @router.get("/me", response_model=UserResponse)
  46. async def read_current_user(
  47. current_user: User = Depends(deps.get_current_user),
  48. ):
  49. """Get current user"""
  50. return current_user
  51. @router.post("/me", response_model=UserResponse)
  52. async def update_current_user_profile(
  53. payload: UserProfileUpdateRequest,
  54. current_user: User = Depends(deps.get_current_user),
  55. session: Session = Depends(deps.get_session),
  56. ):
  57. """Update the caller's own display name and/or contact phone number.
  58. This is the only place phone_number is ever written -- commission
  59. contact info (Commission.contacts) is derived from it, not stored
  60. separately."""
  61. if payload.name is not None:
  62. current_user.name = payload.name
  63. if payload.phone_number is not None:
  64. current_user.phone_number = payload.phone_number
  65. session.add(current_user)
  66. session.commit()
  67. session.refresh(current_user)
  68. return current_user
  69. @router.delete("/me", status_code=204)
  70. async def delete_current_user(
  71. current_user: User = Depends(deps.get_current_user),
  72. session: Session = Depends(deps.get_session),
  73. ):
  74. """Delete current user"""
  75. session.execute(delete(User).where(User.id == current_user.id))
  76. session.commit()
  77. @router.post("/update-password", response_model=UserResponse)
  78. async def reset_current_user_password(
  79. user_update_password: UserUpdatePasswordRequest,
  80. session: Session = Depends(deps.get_session),
  81. current_user: User = Depends(deps.get_current_user),
  82. ):
  83. """Update current user password"""
  84. current_user.hashed_password = get_password_hash(user_update_password.password)
  85. session.add(current_user)
  86. session.commit()
  87. return current_user
  88. @router.post("/register", response_model=UserResponse)
  89. async def register_new_user(
  90. new_user: UserCreateRequest,
  91. session: Session = Depends(deps.get_session),
  92. ):
  93. """Create new user. Does not attach the user to any organization --
  94. a super_admin must add them via POST /organizations/{id}/members
  95. afterward."""
  96. result = session.execute(select(User).where(User.email == new_user.email))
  97. if result.scalars().first() is not None:
  98. raise HTTPException(status_code=400, detail="Cannot use this email address")
  99. user = User(
  100. email=new_user.email,
  101. name=new_user.name,
  102. hashed_password=get_password_hash(new_user.password),
  103. )
  104. session.add(user)
  105. session.commit()
  106. session.refresh(user)
  107. return user
  108. @router.post("/forgot-password", status_code=204)
  109. async def forgot_password(
  110. payload: ForgotPasswordRequest,
  111. session: Session = Depends(deps.get_session),
  112. email_sender: EmailSender = Depends(get_email_sender),
  113. ):
  114. """Always returns 204 regardless of whether the email exists --
  115. prevents account enumeration via response-timing/content differences."""
  116. log_reset_request(session, payload.email)
  117. if is_reset_rate_limited(session, payload.email):
  118. return
  119. user = session.scalar(select(User).where(User.email == payload.email))
  120. if user is not None:
  121. await issue_reset_token(session, user, "reset", email_sender, settings.FRONT_END_URL)
  122. @router.post("/set-password", response_model=UserResponse)
  123. async def set_password_with_token(
  124. payload: SetPasswordWithTokenRequest,
  125. session: Session = Depends(deps.get_session),
  126. ):
  127. """Consumes a reset or invite token, sets the password, marks it used."""
  128. token_hash = hashlib.sha256(payload.token.encode()).hexdigest()
  129. reset_token = session.scalar(
  130. select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
  131. )
  132. if (
  133. reset_token is None
  134. or reset_token.used_at is not None
  135. or reset_token.expires_at < datetime.now(UTC)
  136. ):
  137. raise HTTPException(status_code=400, detail="Invalid or expired link")
  138. user = session.get(User, reset_token.user_id)
  139. user.hashed_password = get_password_hash(payload.password)
  140. user.must_change_password = False
  141. reset_token.used_at = datetime.now(UTC)
  142. session.commit()
  143. session.refresh(user)
  144. return user