diff --git a/src/dispatch/auth/models.py b/src/dispatch/auth/models.py
index e430a3df8665..6257e9f1d53d 100644
--- a/src/dispatch/auth/models.py
+++ b/src/dispatch/auth/models.py
@@ -65,9 +65,23 @@ class DispatchUser(Base, TimeStampMixin):
TSVectorType("email", regconfig="pg_catalog.simple", weights={"email": "A"})
)
- def check_password(self, password):
+ def verify_password(self, password: str) -> bool:
+ """Verify if provided password matches stored hash"""
+ if not password or not self.password:
+ return False
return bcrypt.checkpw(password.encode("utf-8"), self.password)
+ def set_password(self, password: str) -> None:
+ """Set a new password"""
+ if not password:
+ raise ValueError("Password cannot be empty")
+ self.password = hash_password(password)
+
+ def is_owner(self, organization_slug: str) -> bool:
+ """Check if user is an owner in the given organization"""
+ role = self.get_organization_role(organization_slug)
+ return role == UserRoles.owner
+
@property
def token(self):
now = datetime.utcnow()
@@ -165,15 +179,51 @@ class UserRead(UserBase):
class UserUpdate(DispatchBase):
id: PrimaryKey
- password: Optional[str] = Field(None, nullable=True)
projects: Optional[List[UserProject]]
organizations: Optional[List[UserOrganization]]
experimental_features: Optional[bool]
role: Optional[str] = Field(None, nullable=True)
- @validator("password", pre=True)
- def hash(cls, v):
- return hash_password(str(v))
+
+class UserPasswordUpdate(DispatchBase):
+ """Model for password updates only"""
+ current_password: str
+ new_password: str
+
+ @validator("new_password")
+ def validate_password(cls, v):
+ if not v or len(v) < 8:
+ raise ValueError("Password must be at least 8 characters long")
+ # Check for at least one number
+ if not any(c.isdigit() for c in v):
+ raise ValueError("Password must contain at least one number")
+ # Check for at least one uppercase and one lowercase character
+ if not (any(c.isupper() for c in v) and any(c.islower() for c in v)):
+ raise ValueError("Password must contain both uppercase and lowercase characters")
+ return v
+
+ @validator("current_password")
+ def password_required(cls, v):
+ if not v:
+ raise ValueError("Current password is required")
+ return v
+
+
+class AdminPasswordReset(DispatchBase):
+ """Model for admin password resets"""
+ new_password: str
+
+ @validator("new_password")
+ def validate_password(cls, v):
+ if not v or len(v) < 8:
+ raise ValueError("Password must be at least 8 characters long")
+ # Check for at least one number
+ if not any(c.isdigit() for c in v):
+ raise ValueError("Password must contain at least one number")
+ # Check for at least one uppercase and one lowercase character
+ if not (any(c.isupper() for c in v) and any(c.islower() for c in v)):
+ raise ValueError("Password must contain both uppercase and lowercase characters")
+ return v
class UserCreate(DispatchBase):
diff --git a/src/dispatch/auth/views.py b/src/dispatch/auth/views.py
index 4b4d1c3bdaed..e0d9bbaf7037 100644
--- a/src/dispatch/auth/views.py
+++ b/src/dispatch/auth/views.py
@@ -35,6 +35,8 @@
UserRegisterResponse,
UserCreate,
UserUpdate,
+ UserPasswordUpdate,
+ AdminPasswordReset,
)
from .service import get, get_by_email, update, create
@@ -159,7 +161,6 @@ def update_user(
# New user role provided is different than current user role
current_user_organization_role = current_user.get_organization_role(organization)
if current_user_organization_role != UserRoles.owner:
- # We don't allow the role change if user requesting the change does not have owner role
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=[
@@ -174,12 +175,88 @@ def update_user(
UserOrganization(role=user_in.role, organization=OrganizationRead(name=organization))
]
- # we currently only allow user password changes via CLI, not UI/API.
- user_in.password = None
-
return update(db_session=db_session, user=user, user_in=user_in)
+@user_router.post("/{user_id}/change-password", response_model=UserRead)
+def change_password(
+ db_session: DbSession,
+ user_id: PrimaryKey,
+ password_update: UserPasswordUpdate,
+ current_user: CurrentUser,
+ organization: OrganizationSlug,
+):
+ """Change user password with proper validation"""
+ user = get(db_session=db_session, user_id=user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A user with this id does not exist."}],
+ )
+
+ # Only allow users to change their own password or owners to reset
+ if user.id != current_user.id and not current_user.is_owner(organization):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=[{"msg": "Not authorized to change other user passwords"}],
+ )
+
+ # Validate current password if user is changing their own password
+ if user.id == current_user.id:
+ if not user.verify_password(password_update.current_password):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=[{"msg": "Invalid current password"}],
+ )
+
+ # Set new password
+ try:
+ user.set_password(password_update.new_password)
+ db_session.commit()
+ except ValueError as e:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=[{"msg": str(e)}],
+ ) from e
+
+ return user
+
+
+@user_router.post("/{user_id}/reset-password", response_model=UserRead)
+def admin_reset_password(
+ db_session: DbSession,
+ user_id: PrimaryKey,
+ password_reset: AdminPasswordReset,
+ current_user: CurrentUser,
+ organization: OrganizationSlug,
+):
+ """Admin endpoint to reset user password"""
+ # Verify current user is an owner
+ if not current_user.is_owner(organization):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=[{"msg": "Only owners can reset passwords"}],
+ )
+
+ user = get(db_session=db_session, user_id=user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=[{"msg": "A user with this id does not exist."}],
+ )
+
+ try:
+ user.set_password(password_reset.new_password)
+ db_session.commit()
+ except ValueError as e:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=[{"msg": str(e)}],
+ ) from e
+
+ return user
+
+
@auth_router.get("/me", response_model=UserRead)
def get_me(
*,
@@ -206,7 +283,7 @@ def login_user(
db_session: DbSession,
):
user = get_by_email(db_session=db_session, email=user_in.email)
- if user and user.check_password(user_in.password):
+ if user and user.verify_password(user_in.password):
projects = []
for user_project in user.projects:
projects.append(
diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py
index 753020726107..e062946c68a0 100644
--- a/src/dispatch/cli.py
+++ b/src/dispatch/cli.py
@@ -242,7 +242,6 @@ def update_user(email: str, role: str, organization: str):
def reset_user_password(email: str, password: str):
"""Resets a user's password."""
from dispatch.auth import service as user_service
- from dispatch.auth.models import UserUpdate
from dispatch.database.core import SessionLocal
db_session = SessionLocal()
@@ -251,10 +250,14 @@ def reset_user_password(email: str, password: str):
click.secho(f"No user found. Email: {email}", fg="red")
return
- user_service.update(
- user=user, user_in=UserUpdate(id=user.id, password=password), db_session=db_session
- )
- click.secho("User successfully updated.", fg="green")
+ try:
+ # Use the new set_password method which includes validation
+ user.set_password(password)
+ db_session.commit()
+ click.secho("User password successfully updated.", fg="green")
+ except ValueError as e:
+ click.secho(f"Failed to update password: {str(e)}", fg="red")
+ return
@dispatch_cli.group("database")
diff --git a/src/dispatch/forms/models.py b/src/dispatch/forms/models.py
index 5a7bd953f193..435c76a7d4e2 100644
--- a/src/dispatch/forms/models.py
+++ b/src/dispatch/forms/models.py
@@ -9,7 +9,7 @@
from dispatch.individual.models import IndividualContactReadMinimal
from dispatch.models import DispatchBase, TimeStampMixin, PrimaryKey, Pagination, ProjectMixin
from dispatch.project.models import ProjectRead
-from dispatch.incident.models import IncidentReadMinimal
+from dispatch.incident.models import IncidentReadBasic
from dispatch.forms.type.models import FormsTypeRead
from .enums import FormStatus, FormAttorneyStatus
@@ -46,7 +46,7 @@ class FormsBase(DispatchBase):
status: Optional[str] = Field(None, nullable=True)
attorney_status: Optional[str] = Field(None, nullable=True)
project: Optional[ProjectRead]
- incident: Optional[IncidentReadMinimal]
+ incident: Optional[IncidentReadBasic]
attorney_questions: Optional[str] = Field(None, nullable=True)
attorney_analysis: Optional[str] = Field(None, nullable=True)
score: Optional[int]
diff --git a/src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue b/src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue
index 3075135ef4d1..5815027738ea 100644
--- a/src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue
+++ b/src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue
@@ -1,12 +1,14 @@
{{ title }}
-
+