Skip to content

Commit

Permalink
Merge branch 'main' into enhancement/dashboard-filter-by-case-partici…
Browse files Browse the repository at this point in the history
…pants
  • Loading branch information
metroid-samus authored Jan 23, 2025
2 parents 8dca86e + a889722 commit 8de1584
Show file tree
Hide file tree
Showing 5 changed files with 157 additions and 19 deletions.
60 changes: 55 additions & 5 deletions src/dispatch/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
87 changes: 82 additions & 5 deletions src/dispatch/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
UserRegisterResponse,
UserCreate,
UserUpdate,
UserPasswordUpdate,
AdminPasswordReset,
)
from .service import get, get_by_email, update, create

Expand Down Expand Up @@ -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=[
Expand All @@ -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(
*,
Expand All @@ -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(
Expand Down
13 changes: 8 additions & 5 deletions src/dispatch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions src/dispatch/forms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down
12 changes: 10 additions & 2 deletions src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<template>
<v-card variant="outlined" :loading="loading">
<v-card-title>{{ title }}</v-card-title>
<apexchart :type="type" height="250" :options="localOptions" :series="series" />
<apexchart :type="type" height="250" :options="localOptions" :series="sanitize(series)" />
</v-card>
</template>

<script>
import VueApexCharts from "vue3-apexcharts"
import DOMPurify from "dompurify"
export default {
name: "DashboardCard",
Expand Down Expand Up @@ -40,7 +42,13 @@ export default {
required: true,
},
},
methods: {
sanitize(series) {
return series.map((s) => {
return { name: DOMPurify.sanitize(s.name), data: s.data }
})
},
},
data() {
return {
localOptions: JSON.parse(JSON.stringify(this.options)),
Expand Down

0 comments on commit 8de1584

Please sign in to comment.