From 32e6aa9c2d3ad2d2afc86e1e15870ac5d2179e7f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 10 Jun 2026 23:20:42 -0500 Subject: [PATCH 01/21] chore(actions/checkout@v5): Updated checkout version 4 to version 5 --- .github/workflows/deploy-to-gcp.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-to-gcp.yml b/.github/workflows/deploy-to-gcp.yml index 13f9d838..8d410052 100644 --- a/.github/workflows/deploy-to-gcp.yml +++ b/.github/workflows/deploy-to-gcp.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set environment run: | From d8a00dca261d8cceca97b24eaaf9e89802c25a9f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 11 Jun 2026 00:13:02 -0500 Subject: [PATCH 02/21] feat(work_order): Work order notification now names the changes on update notifications --- api/routes/work_orders.py | 18 ++- api/services/work_orders.py | 111 +++++++++++++++++- .../src/views/WorkOrders/WorkOrdersTable.tsx | 7 +- 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/api/routes/work_orders.py b/api/routes/work_orders.py index 9ca7ff44..bf174fdd 100644 --- a/api/routes/work_orders.py +++ b/api/routes/work_orders.py @@ -48,9 +48,13 @@ def get_work_orders( tags=["Work Orders"], ) def create_work_order( - new_work_order: meter.CreateWorkOrder, db: Session = Depends(get_db) + new_work_order: meter.CreateWorkOrder, + user: Users = Depends(get_current_user), + db: Session = Depends(get_db), ): - return work_order_service.create_work_order(db=db, new_work_order=new_work_order) + return work_order_service.create_work_order( + db=db, user=user, new_work_order=new_work_order + ) @work_orders_router.patch( @@ -73,5 +77,11 @@ def patch_work_order( dependencies=[Depends(ScopedUser.Admin)], tags=["Work Orders"], ) -def delete_work_order(work_order_id: int, db: Session = Depends(get_db)): - return work_order_service.delete_work_order(db=db, work_order_id=work_order_id) +def delete_work_order( + work_order_id: int, + user: Users = Depends(get_current_user), + db: Session = Depends(get_db), +): + return work_order_service.delete_work_order( + db=db, user=user, work_order_id=work_order_id + ) diff --git a/api/services/work_orders.py b/api/services/work_orders.py index ee8cae64..aa192a9d 100644 --- a/api/services/work_orders.py +++ b/api/services/work_orders.py @@ -3,7 +3,7 @@ from fastapi import HTTPException from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, undefer from api.models.meter import Meters, MeterActivities from api.models.user import Notifications, NotificationTypeLU, Users @@ -146,7 +146,7 @@ def list_work_orders( def create_work_order( - db: Session, new_work_order: meter.CreateWorkOrder + db: Session, user: Users, new_work_order: meter.CreateWorkOrder ) -> meter.WorkOrder: open_status = db.scalars( select(workOrderStatusLU).where(workOrderStatusLU.name == "Open") @@ -178,6 +178,7 @@ def create_work_order( db=db, work_order=work_order, action="created", + created_by_user_id=user.id, ) db.commit() @@ -217,6 +218,15 @@ def update_work_order( _work_order_query().where(workOrders.id == patch_work_order_form.work_order_id) ).first() + old_values = { + "title": work_order.title, + "description": work_order.description, + "status_id": work_order.status_id, + "notes": work_order.notes, + "creator": work_order.creator, + "assigned_user_id": work_order.assigned_user_id, + } + if user.user_role.name == "Technician" and work_order.assigned_user_id != user.id: raise HTTPException( status_code=403, @@ -252,6 +262,7 @@ def update_work_order( work_order=work_order, action="updated", created_by_user_id=user.id, + old_values=old_values, ) db.commit() @@ -274,7 +285,7 @@ def update_work_order( ) -def delete_work_order(db: Session, work_order_id: int): +def delete_work_order(db: Session, user: Users, work_order_id: int): work_order = db.scalars( select(workOrders).where(workOrders.id == work_order_id) ).first() @@ -290,6 +301,7 @@ def delete_work_order(db: Session, work_order_id: int): db=db, work_order=work_order, action="deleted", + created_by_user_id=user.id, ) db.commit() @@ -299,11 +311,82 @@ def delete_work_order(db: Session, work_order_id: int): return {"status": "success"} +def _format_user_display(user: Users | None) -> str: + if not user: + return "Unassigned" + + name = user.display_name or user.full_name or user.email or str(user.id) + email = user.email + + return f"{name} ({email})" if email and email != name else name + + +def _get_user_display_by_id(db: Session, user_id: int | None) -> str: + if not user_id: + return "Unassigned" + + user = db.scalars( + select(Users).options(undefer(Users.email)).where(Users.id == user_id) + ).first() + + return _format_user_display(user) + + +def _build_work_order_change_messages( + db: Session, + old_values: dict, + work_order: workOrders, +) -> list[str]: + changes = [] + + field_labels = { + "title": "Title", + "description": "Description", + "notes": "Notes", + "creator": "Creator", + } + + for field, label in field_labels.items(): + old_value = old_values.get(field) + new_value = getattr(work_order, field) + + if old_value != new_value: + changes.append( + f"{label} changed from {old_value or 'blank'} to {new_value or 'blank'}" + ) + + if old_values.get("status_id") != work_order.status_id: + old_status = db.scalars( + select(workOrderStatusLU.name).where( + workOrderStatusLU.id == old_values.get("status_id") + ) + ).first() + + new_status = db.scalars( + select(workOrderStatusLU.name).where( + workOrderStatusLU.id == work_order.status_id + ) + ).first() + + changes.append( + f"Status changed from {old_status or 'blank'} to {new_status or 'blank'}" + ) + + if old_values.get("assigned_user_id") != work_order.assigned_user_id: + old_user = _get_user_display_by_id(db, old_values.get("assigned_user_id")) + new_user = _get_user_display_by_id(db, work_order.assigned_user_id) + + changes.append(f"Assigned user changed from {old_user} to {new_user}") + + return changes + + def _create_work_order_notifications( db: Session, work_order: workOrders, - action: str, # "created" or "updated" or "deleted" + action: str, created_by_user_id: int | None = None, + old_values: dict | None = None, ): notification_type = db.scalars( select(NotificationTypeLU).where(NotificationTypeLU.name == "work_order") @@ -329,8 +412,26 @@ def _create_work_order_notifications( if not recipient_user_ids: return + changed_by = _get_user_display_by_id(db, created_by_user_id) + title = f"Work order {action}: {work_order.title}" - message = f"Work order #{work_order.id} has been {action}." + + if action == "updated" and old_values: + change_messages = _build_work_order_change_messages( + db=db, + old_values=old_values, + work_order=work_order, + ) + + if change_messages: + message = ( + f"{changed_by} updated Work order #{work_order.id}. " + f"Changes: {'; '.join(change_messages)}." + ) + else: + message = f"{changed_by} updated Work order #{work_order.id}." + else: + message = f"{changed_by} {action} Work order #{work_order.id}." notifications = [ Notifications( diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index d32bbc3f..a393d734 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -256,8 +256,11 @@ export const WorkOrdersTable = () => { field: "work_order_id", headerName: "ID", type: "number", + headerAlign: "left", + align: "left", flex: 1, - minWidth: 50, + minWidth: 75, + valueFormatter: (value: number) => `#${value}`, }, { field: "date_created", @@ -607,7 +610,7 @@ export const WorkOrdersTable = () => { pagination: { paginationModel: { page: 0, pageSize: 25 } }, columns: { columnVisibilityModel: { - work_order_id: false, + work_order_id: true, creator: hasAdminScope, associated_activities: hasAdminScope, assigned_user_id: hasAdminScope, From 880222e929579d42cf3708ca2a98ca37c9a1e664 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sun, 28 Jun 2026 23:09:30 -0500 Subject: [PATCH 03/21] feat(auth): require complex passwords --- README.md | 20 +++ api/auth/password_policy.py | 163 ++++++++++++++++++ api/main.py | 8 + api/models/user.py | 14 ++ api/routes/admin.py | 86 ++++++--- api/routes/settings.py | 88 +++++++++- api/schemas/security.py | 20 ++- api/schemas/settings.py | 24 +++ frontend/src/interfaces/User.ts | 6 + frontend/src/service/users.ts | 24 ++- frontend/src/utils/PasswordPolicy.ts | 60 +++++++ frontend/src/utils/index.ts | 1 + frontend/src/views/Settings.tsx | 43 ++++- .../Settings/components/SecuritySection.tsx | 160 ++++++++++++++--- .../views/UserManagement/UserDetailsCard.tsx | 17 +- ...ssword_security_metadata_to_users.down.sql | 7 + ...password_security_metadata_to_users.up.sql | 7 + 17 files changed, 682 insertions(+), 66 deletions(-) create mode 100644 api/auth/password_policy.py create mode 100644 frontend/src/utils/PasswordPolicy.ts create mode 100644 migrations/20260629000000_add_password_security_metadata_to_users.down.sql create mode 100644 migrations/20260629000000_add_password_security_metadata_to_users.up.sql diff --git a/README.md b/README.md index 35bacfb3..ae9ada95 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,26 @@ --- +### Password Security + +New and changed passwords must be at least 12 characters and include lowercase +letters, uppercase letters, numbers, and symbols. Passwords that include obvious +account identifiers such as the username, email, full name, or display name are +rejected. + +Existing weak passwords do not block users from signing in. After a successful +sign-in, the application records the current password's last known strength +status so the Settings page can warn the user if their current password is weak. +The Settings password section also shows when the password was last changed. + +When users type a new password, the UI shows strength feedback immediately. On +blur, the backend checks the candidate password against the Have I Been Pwned +Pwned Passwords range API using k-anonymity: only the first five characters of a +SHA-1 hash are sent, and the raw password is never sent to the third-party +service. Known compromised passwords are rejected when a password is changed. + +--- + ### Tech Stack | Layer | Technology | diff --git a/api/auth/password_policy.py b/api/auth/password_policy.py new file mode 100644 index 00000000..22409f3c --- /dev/null +++ b/api/auth/password_policy.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import hashlib +import re +import urllib.error +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone + +from api.models.user import Users + + +MIN_PASSWORD_LENGTH = 12 +HIBP_RANGE_API_URL = "https://api.pwnedpasswords.com/range" +HIBP_USER_AGENT = "WaterManagerDB password checker" +HIBP_TIMEOUT_SECONDS = 3 + + +@dataclass(frozen=True) +class PasswordEvaluation: + score: int + label: str + is_policy_compliant: bool + missing_requirements: list[str] + compromised_count: int | None = None + compromised_checked_at: datetime | None = None + compromised_check_error: str | None = None + + +def evaluate_password_strength( + password: str, + user: Users | None = None, + compromised_count: int | None = None, + compromised_checked_at: datetime | None = None, + compromised_check_error: str | None = None, +) -> PasswordEvaluation: + missing_requirements = [] + + if len(password) < MIN_PASSWORD_LENGTH: + missing_requirements.append( + f"Use at least {MIN_PASSWORD_LENGTH} characters." + ) + if not re.search(r"[a-z]", password): + missing_requirements.append("Add a lowercase letter.") + if not re.search(r"[A-Z]", password): + missing_requirements.append("Add an uppercase letter.") + if not re.search(r"\d", password): + missing_requirements.append("Add a number.") + if not re.search(r"[^A-Za-z0-9]", password): + missing_requirements.append("Add a symbol.") + + lower_password = password.lower() + for value, label in _user_identifiers(user): + if value and len(value) >= 3 and value.lower() in lower_password: + missing_requirements.append(f"Do not include your {label}.") + + score = 0 + score += min(len(password), 16) // 4 + score += 1 if re.search(r"[a-z]", password) else 0 + score += 1 if re.search(r"[A-Z]", password) else 0 + score += 1 if re.search(r"\d", password) else 0 + score += 1 if re.search(r"[^A-Za-z0-9]", password) else 0 + score += 1 if len(password) >= 16 else 0 + score = min(score, 5) + + if missing_requirements: + score = min(score, 2) + + label = "Weak" + if score >= 5: + label = "Strong" + elif score >= 3: + label = "Moderate" + + return PasswordEvaluation( + score=score, + label=label, + is_policy_compliant=not missing_requirements, + missing_requirements=missing_requirements, + compromised_count=compromised_count, + compromised_checked_at=compromised_checked_at, + compromised_check_error=compromised_check_error, + ) + + +def check_pwned_password(password: str) -> tuple[int | None, datetime, str | None]: + checked_at = datetime.now(timezone.utc) + password_hash = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() + prefix = password_hash[:5] + suffix = password_hash[5:] + request = urllib.request.Request( + f"{HIBP_RANGE_API_URL}/{prefix}", + headers={ + "Add-Padding": "true", + "User-Agent": HIBP_USER_AGENT, + }, + ) + + try: + with urllib.request.urlopen(request, timeout=HIBP_TIMEOUT_SECONDS) as response: + body = response.read().decode("utf-8") + except (OSError, urllib.error.URLError, TimeoutError) as exc: + return None, checked_at, str(exc) + + for line in body.splitlines(): + found_suffix, _, count = line.partition(":") + if found_suffix == suffix: + try: + return int(count), checked_at, None + except ValueError: + return None, checked_at, "Unexpected compromised password response." + + return 0, checked_at, None + + +def evaluate_password( + password: str, + user: Users | None = None, + include_compromised_check: bool = False, +) -> PasswordEvaluation: + compromised_count = None + compromised_checked_at = None + compromised_check_error = None + + if include_compromised_check: + compromised_count, compromised_checked_at, compromised_check_error = ( + check_pwned_password(password) + ) + + return evaluate_password_strength( + password=password, + user=user, + compromised_count=compromised_count, + compromised_checked_at=compromised_checked_at, + compromised_check_error=compromised_check_error, + ) + + +def apply_password_evaluation(user: Users, evaluation: PasswordEvaluation) -> None: + user.password_strength_score = evaluation.score + user.password_strength_label = evaluation.label + user.password_policy_compliant = evaluation.is_policy_compliant + + if evaluation.compromised_checked_at is not None: + user.password_compromised_checked_at = evaluation.compromised_checked_at + user.password_compromised_count = evaluation.compromised_count + + +def _user_identifiers(user: Users | None) -> list[tuple[str | None, str]]: + if user is None: + return [] + + values: list[tuple[str | None, str]] = [ + (user.username, "username"), + (user.email, "email"), + (user.full_name, "name"), + (user.display_name, "display name"), + ] + + if user.email and "@" in user.email: + values.append((user.email.split("@", 1)[0], "email")) + + return values diff --git a/api/main.py b/api/main.py index facd1a99..8637186e 100644 --- a/api/main.py +++ b/api/main.py @@ -26,6 +26,7 @@ ) from api.routes.wells import authenticated_well_router, public_well_router from api.auth.session_tracking import create_user_session, touch_user_session +from api.auth.password_policy import apply_password_evaluation, evaluate_password from api.security import ( authenticate_user, create_access_token, @@ -108,6 +109,13 @@ def login_for_access_token( headers={"WWW-Authenticate": "Bearer"}, ) + password_evaluation = evaluate_password( + form_data.password, + user=user, + include_compromised_check=True, + ) + apply_password_evaluation(user, password_evaluation) + user_session = create_user_session(db=db, user=user, request=request) access_token = create_access_token( diff --git a/api/models/user.py b/api/models/user.py index 9d68c927..48e8971e 100644 --- a/api/models/user.py +++ b/api/models/user.py @@ -22,6 +22,20 @@ class Users(Base): display_name: Mapped[str] = mapped_column(String, nullable=True) redirect_page: Mapped[str] = mapped_column(String, nullable=True, default="/") avatar_img: Mapped[str] = mapped_column(String, nullable=True) + password_changed_at: Mapped[Optional[DateTime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + password_strength_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + password_strength_label: Mapped[Optional[str]] = mapped_column(String, nullable=True) + password_policy_compliant: Mapped[Optional[bool]] = mapped_column( + Boolean, nullable=True + ) + password_compromised_checked_at: Mapped[Optional[DateTime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + password_compromised_count: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True + ) notifications: Mapped[List["Notifications"]] = relationship( "Notifications", back_populates="user", diff --git a/api/routes/admin.py b/api/routes/admin.py index 60bf2be7..9b4a0d31 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -1,11 +1,10 @@ -from datetime import timedelta +from datetime import datetime, timezone, timedelta from fastapi import Depends, APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload, undefer from sqlalchemy import select from typing import List -from passlib.context import CryptContext from api.models.user import Users, UserRoles, SecurityScopes @@ -15,7 +14,12 @@ from api.routes.utils import _patch from api.auth.dependencies import ScopedUser from api.auth.session_tracking import create_user_session -from api.security import create_access_token, ACCESS_TOKEN_EXPIRE_HOURS +from api.auth.password_policy import apply_password_evaluation, evaluate_password +from api.security import ( + create_access_token, + ACCESS_TOKEN_EXPIRE_HOURS, + get_password_hash, +) from api.config import settings from pathlib import Path @@ -24,16 +28,41 @@ import os import subprocess -import datetime +import datetime as dt -admin_router = APIRouter() -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +admin_router = APIRouter() BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") BACKUP_PREFIX = os.getenv("GCP_BACKUP_PREFIX", "") BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "30")) load_dotenv(os.getenv("APPDB_ENV", ".env")) -DATABASE_URL = os.getenv("DATABASE_URL", "") +DATABASE_URL = os.getenv("DATABASE_URL", "") + + +def _validate_new_password(password: str, user: Users) -> None: + evaluation = evaluate_password( + password, + user=user, + include_compromised_check=True, + ) + if not evaluation.is_policy_compliant: + raise HTTPException( + status_code=400, + detail={ + "message": "Password does not meet password requirements.", + "missing_requirements": evaluation.missing_requirements, + }, + ) + + if evaluation.compromised_count is not None and evaluation.compromised_count > 0: + raise HTTPException( + status_code=400, + detail="Password appears in known compromised password lists.", + ) + + user.hashed_password = get_password_hash(password) + user.password_changed_at = datetime.now(timezone.utc) + apply_password_evaluation(user, evaluation) # define response models @@ -43,17 +72,19 @@ dependencies=[Depends(ScopedUser.Admin)], tags=["Admin"], ) -def update_user_password( +def update_user_password( updatedUserPassword: security.UpdatedUserPassword, - db: Session = Depends(get_db), -): - user = db.scalars( - select(Users).where(Users.id == updatedUserPassword.user_id) - ).first() - - user.hashed_password = pwd_context.hash(updatedUserPassword.new_password) - db.commit() - db.refresh(user) + db: Session = Depends(get_db), +): + user = db.scalars( + select(Users).where(Users.id == updatedUserPassword.user_id) + ).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + _validate_new_password(updatedUserPassword.new_password, user) + db.commit() + db.refresh(user) return user @@ -90,17 +121,18 @@ def update_user( tags=["Admin"], ) def create_user(user: security.NewUser, db: Session = Depends(get_db)): - new_user = Users( + new_user = Users( username=user.username, email=user.email, full_name=user.full_name, - display_name=user.display_name, - user_role_id=user.user_role_id, - disabled=user.disabled, - hashed_password=pwd_context.hash(user.password), - ) - - db.add(new_user) + display_name=user.display_name, + user_role_id=user.user_role_id, + disabled=user.disabled, + hashed_password="", + ) + _validate_new_password(user.password, new_user) + + db.add(new_user) db.commit() qualified_user = db.scalars( @@ -423,7 +455,7 @@ def backup_and_send(): raise ValueError("DATABASE_URL environment variable is not set") # Use UTC-aware timestamp - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H%M%S") + timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d-%H%M%S") filename = f"backup-{timestamp}.dump" local_path = Path(f"/tmp/{filename}") @@ -441,7 +473,7 @@ def backup_and_send(): local_path.unlink(missing_ok=True) # Delete old backups (> BACKUP_RETENTION_DAYS) using UTC-aware cutoff - cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + cutoff_date = dt.datetime.now(dt.timezone.utc) - dt.timedelta( days=BACKUP_RETENTION_DAYS ) blobs = client.list_blobs(BUCKET_NAME, prefix=BACKUP_PREFIX) diff --git a/api/routes/settings.py b/api/routes/settings.py index 4d2ce294..1cd3adc5 100644 --- a/api/routes/settings.py +++ b/api/routes/settings.py @@ -1,4 +1,5 @@ from base64 import b64encode +from datetime import datetime, timezone from io import BytesIO from fastapi import Depends, APIRouter, HTTPException, File, UploadFile @@ -9,6 +10,10 @@ from api.session import get_db from api.security import get_current_user, get_password_hash, verify_password from api.models.user import Users +from api.auth.password_policy import ( + apply_password_evaluation, + evaluate_password, +) settings_router = APIRouter() @@ -22,6 +27,22 @@ } +def _serialize_datetime(value): + return value.isoformat() if value else None + + +def _password_evaluation_response(evaluation): + return settings.PasswordEvaluationResponse( + score=evaluation.score, + label=evaluation.label, + is_policy_compliant=evaluation.is_policy_compliant, + missing_requirements=evaluation.missing_requirements, + compromised_count=evaluation.compromised_count, + compromised_checked_at=_serialize_datetime(evaluation.compromised_checked_at), + compromised_check_error=evaluation.compromised_check_error, + ) + + @settings_router.get( "/settings/redirect_page", tags=["settings"], @@ -75,6 +96,53 @@ def post_redirect_page( return {"message": "Display name updated", "display_name": db_user.display_name} +@settings_router.get( + "/settings/password_status", + response_model=settings.PasswordStatusResponse, + tags=["settings"], +) +def get_password_status( + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + return settings.PasswordStatusResponse( + password_changed_at=_serialize_datetime(db_user.password_changed_at), + password_strength_score=db_user.password_strength_score, + password_strength_label=db_user.password_strength_label, + password_policy_compliant=db_user.password_policy_compliant, + password_compromised_checked_at=_serialize_datetime( + db_user.password_compromised_checked_at + ), + password_compromised_count=db_user.password_compromised_count, + ) + + +@settings_router.post( + "/settings/password/evaluate", + response_model=settings.PasswordEvaluationResponse, + tags=["settings"], +) +def post_password_evaluate( + request: settings.PasswordEvaluateRequest, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + evaluation = evaluate_password( + request.password, + user=db_user, + include_compromised_check=True, + ) + return _password_evaluation_response(evaluation) + + @settings_router.post( "/settings/password_reset", tags=["settings"], @@ -97,13 +165,29 @@ def post_password_reset( detail="New password must be different from current password", ) - if len(update.new_password) < 8: + evaluation = evaluate_password( + update.new_password, + user=db_user, + include_compromised_check=True, + ) + if not evaluation.is_policy_compliant: + raise HTTPException( + status_code=400, + detail={ + "message": "New password does not meet password requirements.", + "missing_requirements": evaluation.missing_requirements, + }, + ) + + if evaluation.compromised_count is not None and evaluation.compromised_count > 0: raise HTTPException( status_code=400, - detail="New password must be at least 8 characters long", + detail="New password appears in known compromised password lists.", ) db_user.hashed_password = get_password_hash(update.new_password) + db_user.password_changed_at = datetime.now(timezone.utc) + apply_password_evaluation(db_user, evaluation) try: db.commit() diff --git a/api/schemas/security.py b/api/schemas/security.py index 8252a9cf..c8cf0789 100644 --- a/api/schemas/security.py +++ b/api/schemas/security.py @@ -1,4 +1,6 @@ -from api.schemas.base import ORMBase +from datetime import datetime + +from api.schemas.base import ORMBase from pydantic import BaseModel @@ -37,18 +39,24 @@ class NewUser(ORMBase): class User(ORMBase): - username: str | None = None - email: str | None = None - full_name: str | None = None - disabled: bool + username: str | None = None + email: str | None = None + full_name: str | None = None + disabled: bool user_role_id: int user_role: UserRole | None = None - display_name: str | None = None + display_name: str | None = None redirect_page: str | None = None avatar_img: str | None = None + password_changed_at: datetime | None = None + password_strength_score: int | None = None + password_strength_label: str | None = None + password_policy_compliant: bool | None = None + password_compromised_checked_at: datetime | None = None + password_compromised_count: int | None = None class ImpersonationContext(BaseModel): diff --git a/api/schemas/settings.py b/api/schemas/settings.py index 1968f9b2..ff00df45 100644 --- a/api/schemas/settings.py +++ b/api/schemas/settings.py @@ -1,4 +1,5 @@ from api.schemas.base import ORMBase +from pydantic import BaseModel class RedirectPageUpdate(ORMBase): @@ -12,3 +13,26 @@ class DisplayNameUpdate(ORMBase): class PasswordResetRequest(ORMBase): current_password: str new_password: str + + +class PasswordEvaluateRequest(BaseModel): + password: str + + +class PasswordEvaluationResponse(BaseModel): + score: int + label: str + is_policy_compliant: bool + missing_requirements: list[str] + compromised_count: int | None = None + compromised_checked_at: str | None = None + compromised_check_error: str | None = None + + +class PasswordStatusResponse(BaseModel): + password_changed_at: str | None = None + password_strength_score: int | None = None + password_strength_label: str | None = None + password_policy_compliant: bool | None = None + password_compromised_checked_at: str | None = None + password_compromised_count: int | None = None diff --git a/frontend/src/interfaces/User.ts b/frontend/src/interfaces/User.ts index de14c955..842b6ad9 100644 --- a/frontend/src/interfaces/User.ts +++ b/frontend/src/interfaces/User.ts @@ -13,4 +13,10 @@ export interface User { redirect_page?: string; avatar_img?: string | null; password?: string; + password_changed_at?: string | null; + password_strength_score?: number | null; + password_strength_label?: string | null; + password_policy_compliant?: boolean | null; + password_compromised_checked_at?: string | null; + password_compromised_count?: number | null; } diff --git a/frontend/src/service/users.ts b/frontend/src/service/users.ts index 6cc9f0fc..af3a22c2 100644 --- a/frontend/src/service/users.ts +++ b/frontend/src/service/users.ts @@ -13,6 +13,20 @@ import { UserRole, } from "@/interfaces"; +async function getErrorMessage(response: Response) { + try { + const body = await response.json(); + const detail = body?.detail; + if (typeof detail === "string") return detail; + if (detail?.message && Array.isArray(detail?.missing_requirements)) { + return `${detail.message} ${detail.missing_requirements.join(" ")}`; + } + if (detail?.message) return detail.message; + } catch {} + + return `Request failed with status ${response.status}`; +} + export function useGetRoles(options?: UseQueryOptions) { const apiClient = useApiClient(); const route = "roles"; @@ -85,8 +99,9 @@ export function useCreateUser(onSuccess: Function) { }); throw Error("Incomplete form, check network logs for details"); } else { - enqueueSnackbar("Unknown Error Occurred!", { variant: "error" }); - throw Error("Unknown Error: " + response.status); + const message = await getErrorMessage(response); + enqueueSnackbar(message, { variant: "error" }); + throw Error(message); } } else { onSuccess(); @@ -254,8 +269,9 @@ export function useUpdateUserPassword(onSuccess: Function) { }); throw Error("Incomplete form, check network logs for details"); } else { - enqueueSnackbar("Unknown Error Occurred!", { variant: "error" }); - throw Error("Unknown Error: " + response.status); + const message = await getErrorMessage(response); + enqueueSnackbar(message, { variant: "error" }); + throw Error(message); } } else { onSuccess(); diff --git a/frontend/src/utils/PasswordPolicy.ts b/frontend/src/utils/PasswordPolicy.ts new file mode 100644 index 00000000..75e34115 --- /dev/null +++ b/frontend/src/utils/PasswordPolicy.ts @@ -0,0 +1,60 @@ +export type PasswordEvaluation = { + score: number; + label: string; + is_policy_compliant: boolean; + missing_requirements: string[]; + compromised_count?: number | null; + compromised_checked_at?: string | null; + compromised_check_error?: string | null; +}; + +export type PasswordStatus = { + password_changed_at?: string | null; + password_strength_score?: number | null; + password_strength_label?: string | null; + password_policy_compliant?: boolean | null; + password_compromised_checked_at?: string | null; + password_compromised_count?: number | null; +}; + +export const MIN_PASSWORD_LENGTH = 12; + +export function evaluatePasswordLocally(password: string): PasswordEvaluation { + const missing_requirements: string[] = []; + + if (password.length < MIN_PASSWORD_LENGTH) { + missing_requirements.push(`Use at least ${MIN_PASSWORD_LENGTH} characters.`); + } + if (!/[a-z]/.test(password)) { + missing_requirements.push("Add a lowercase letter."); + } + if (!/[A-Z]/.test(password)) { + missing_requirements.push("Add an uppercase letter."); + } + if (!/\d/.test(password)) { + missing_requirements.push("Add a number."); + } + if (!/[^A-Za-z0-9]/.test(password)) { + missing_requirements.push("Add a symbol."); + } + + let score = 0; + score += Math.floor(Math.min(password.length, 16) / 4); + score += /[a-z]/.test(password) ? 1 : 0; + score += /[A-Z]/.test(password) ? 1 : 0; + score += /\d/.test(password) ? 1 : 0; + score += /[^A-Za-z0-9]/.test(password) ? 1 : 0; + score += password.length >= 16 ? 1 : 0; + score = Math.min(score, 5); + + if (missing_requirements.length > 0) { + score = Math.min(score, 2); + } + + return { + score, + label: score >= 5 ? "Strong" : score >= 3 ? "Moderate" : "Weak", + is_policy_compliant: missing_requirements.length === 0, + missing_requirements, + }; +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 869f677f..a5e62059 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -11,4 +11,5 @@ export * from "./GetRoleColor"; export * from "./MemoryUtils"; export * from "./MonitoredWellsUtils"; export * from "./NumberDataFormatter"; +export * from "./PasswordPolicy"; export * from "./RouteSearch"; diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index f5b2f334..adf0d172 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -19,7 +19,12 @@ import { useFetchWithAuth } from "@/hooks"; import { SecurityScope, UserSessionsResponse } from "@/interfaces"; import { Route } from "@/routes/settings"; import { getTrackedSession } from "@/utils/SessionTracking"; -import { clearSavedQueryLocalStorage } from "@/utils"; +import { + PasswordEvaluation, + PasswordStatus, + clearSavedQueryLocalStorage, + evaluatePasswordLocally, +} from "@/utils"; import { KnownDevicesSection, PreferencesSection, @@ -36,7 +41,12 @@ const passwordSchema = yup.object().shape({ currentPassword: yup.string().required("Current password is required"), newPassword: yup .string() - .min(8, "New password must be at least 8 characters") + .test( + "password-policy", + "New password does not meet password requirements", + (value) => + !!value && evaluatePasswordLocally(value).is_policy_compliant, + ) .required("New password is required"), confirmPassword: yup .string() @@ -224,6 +234,7 @@ export const Settings = () => { setShowCurrentPassword(false); setShowNewPassword(false); setShowConfirmPassword(false); + queryClient.invalidateQueries(["passwordStatus"]); }, onError: (error: Error) => { enqueueSnackbar(error.message || "Failed to update password.", { @@ -232,6 +243,24 @@ export const Settings = () => { }, }); + const passwordStatusQuery = useQuery({ + queryKey: ["passwordStatus"], + queryFn: async () => + fetchWithAuth({ + method: "GET", + route: "/settings/password_status", + }), + }); + + const passwordEvaluationMutation = useMutation({ + mutationFn: async (password: string): Promise => + fetchWithAuth({ + method: "POST", + route: "/settings/password/evaluate", + body: { password }, + }), + }); + const { control: passwordControl, handleSubmit: handlePasswordSubmit, @@ -257,6 +286,11 @@ export const Settings = () => { }); }; + const onNewPasswordBlur = (password: string) => { + if (!password) return; + passwordEvaluationMutation.mutate(password); + }; + const avatarMutation = useMutation({ mutationFn: async (file: File) => { const formData = new FormData(); @@ -469,6 +503,11 @@ export const Settings = () => { showConfirmPassword={showConfirmPassword} setShowConfirmPassword={setShowConfirmPassword} isSavingPassword={passwordMutation.isLoading} + passwordStatus={passwordStatusQuery.data} + passwordEvaluation={passwordEvaluationMutation.data} + checkedPassword={passwordEvaluationMutation.variables} + isCheckingPassword={passwordEvaluationMutation.isLoading} + onNewPasswordBlur={onNewPasswordBlur} /> diff --git a/frontend/src/views/Settings/components/SecuritySection.tsx b/frontend/src/views/Settings/components/SecuritySection.tsx index 7b23ba98..77926731 100644 --- a/frontend/src/views/Settings/components/SecuritySection.tsx +++ b/frontend/src/views/Settings/components/SecuritySection.tsx @@ -1,13 +1,27 @@ -import { Controller, type Control, type FieldErrors } from "react-hook-form"; import { + Controller, + useWatch, + type Control, + type FieldErrors, +} from "react-hook-form"; +import { + Alert, Box, Button, IconButton, InputAdornment, + LinearProgress, Stack, TextField, + Typography, } from "@mui/material"; import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { + PasswordEvaluation, + PasswordStatus, + evaluatePasswordLocally, + toGMT6String, +} from "@/utils"; import { SectionSurface } from "./SectionSurface"; type PasswordFormValues = { @@ -28,6 +42,11 @@ export function SecuritySection({ showConfirmPassword, setShowConfirmPassword, isSavingPassword, + passwordStatus, + passwordEvaluation, + checkedPassword, + isCheckingPassword, + onNewPasswordBlur, }: { passwordControl: Control; passwordErrors: FieldErrors; @@ -42,11 +61,57 @@ export function SecuritySection({ showConfirmPassword: boolean; setShowConfirmPassword: React.Dispatch>; isSavingPassword: boolean; + passwordStatus?: PasswordStatus; + passwordEvaluation?: PasswordEvaluation; + checkedPassword?: string; + isCheckingPassword: boolean; + onNewPasswordBlur: (password: string) => void; }) { + const newPassword = useWatch({ + control: passwordControl, + name: "newPassword", + }); + const localEvaluation = evaluatePasswordLocally(newPassword ?? ""); + const isShowingCheckedPassword = checkedPassword === newPassword; + const displayEvaluation = + isShowingCheckedPassword && passwordEvaluation + ? passwordEvaluation + : localEvaluation; + const strengthColor = + localEvaluation.score >= 5 + ? "success" + : localEvaluation.score >= 3 + ? "warning" + : "error"; + const passwordChangedAt = passwordStatus?.password_changed_at + ? toGMT6String(new Date(passwordStatus.password_changed_at)) + : "Not recorded"; + const currentPasswordIsWeak = passwordStatus?.password_policy_compliant === false; + const currentPasswordIsCompromised = + (passwordStatus?.password_compromised_count ?? 0) > 0; + return ( + + + Password last changed: {passwordChangedAt} + + {currentPasswordIsWeak || currentPasswordIsCompromised ? ( + + Your current password is{" "} + {currentPasswordIsCompromised + ? "known to be compromised" + : "weaker than the current policy"} + . Update it with a strong password when you can. + + ) : passwordStatus?.password_policy_compliant == null ? ( + + Current password strength has not been checked yet. + + ) : null} + ( - - - setShowNewPassword((current) => !current) - } - edge="end" - > - {showNewPassword ? : } - - - ), - }} - /> + + { + field.onBlur(); + onNewPasswordBlur(field.value); + }} + InputProps={{ + endAdornment: ( + + + setShowNewPassword((current) => !current) + } + edge="end" + > + {showNewPassword ? : } + + + ), + }} + /> + {newPassword ? ( + + + + Password strength: {localEvaluation.label} + + {isCheckingPassword ? ( + + Checking compromised lists... + + ) : isShowingCheckedPassword && + displayEvaluation.compromised_count != null ? ( + 0 + ? "error" + : "success.main" + } + > + {displayEvaluation.compromised_count > 0 + ? "Found in compromised lists" + : "No compromised match found"} + + ) : null} + + + + ) : null} + )} /> = Yup.object().shape({ full_name: Yup.string().required("Please enter a full name."), @@ -45,7 +46,11 @@ const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ email: Yup.string().required("Please enter an email."), disabled: Yup.boolean().required("Please indicate if user is active."), user_role: Yup.object().required("Please indicate the users role."), - password: Yup.string(), + password: Yup.string().test( + "password-policy", + "Password does not meet password requirements.", + (value) => !value || evaluatePasswordLocally(value).is_policy_compliant, + ), }); const formatSubmission = (user: User) => { @@ -147,6 +152,11 @@ export const UserDetailsCard = ({ enqueueSnackbar("Please provide a password.", { variant: "error" }); return; } + const evaluation = evaluatePasswordLocally(user.password); + if (!evaluation.is_policy_compliant) { + enqueueSnackbar(evaluation.missing_requirements[0], { variant: "error" }); + return; + } createUser.mutate(formatSubmission(user)); }; @@ -158,6 +168,11 @@ export const UserDetailsCard = ({ enqueueSnackbar("Please provide a new password.", { variant: "error" }); return; } + const evaluation = evaluatePasswordLocally(newPassword); + if (!evaluation.is_policy_compliant) { + enqueueSnackbar(evaluation.missing_requirements[0], { variant: "error" }); + return; + } const updatedUserPassword: UpdatedUserPassword = { user_id: userId, new_password: newPassword, diff --git a/migrations/20260629000000_add_password_security_metadata_to_users.down.sql b/migrations/20260629000000_add_password_security_metadata_to_users.down.sql new file mode 100644 index 00000000..ab578d75 --- /dev/null +++ b/migrations/20260629000000_add_password_security_metadata_to_users.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE public."Users" + DROP COLUMN IF EXISTS password_changed_at, + DROP COLUMN IF EXISTS password_strength_score, + DROP COLUMN IF EXISTS password_strength_label, + DROP COLUMN IF EXISTS password_policy_compliant, + DROP COLUMN IF EXISTS password_compromised_checked_at, + DROP COLUMN IF EXISTS password_compromised_count; diff --git a/migrations/20260629000000_add_password_security_metadata_to_users.up.sql b/migrations/20260629000000_add_password_security_metadata_to_users.up.sql new file mode 100644 index 00000000..d0a67f91 --- /dev/null +++ b/migrations/20260629000000_add_password_security_metadata_to_users.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE public."Users" + ADD COLUMN password_changed_at timestamp with time zone NULL, + ADD COLUMN password_strength_score integer NULL, + ADD COLUMN password_strength_label varchar NULL, + ADD COLUMN password_policy_compliant boolean NULL, + ADD COLUMN password_compromised_checked_at timestamp with time zone NULL, + ADD COLUMN password_compromised_count integer NULL; From 48339d2b3ac0490e437f034667e362649f7120a0 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 29 Jun 2026 16:27:53 -0500 Subject: [PATCH 04/21] chore(auth): add the new complex password requirements to password resets --- api/routes/admin.py | 56 ++++- api/schemas/security.py | 20 +- frontend/src/service/users.ts | 20 ++ .../views/UserManagement/UserDetailsCard.tsx | 221 +++++++++++++++--- 4 files changed, 279 insertions(+), 38 deletions(-) diff --git a/api/routes/admin.py b/api/routes/admin.py index 9b4a0d31..5d163200 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -1,4 +1,6 @@ from datetime import datetime, timezone, timedelta +import secrets +import string from fastapi import Depends, APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse @@ -37,6 +39,27 @@ BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "30")) load_dotenv(os.getenv("APPDB_ENV", ".env")) DATABASE_URL = os.getenv("DATABASE_URL", "") +PASSWORD_GENERATION_ATTEMPTS = 8 +PASSWORD_GENERATION_LENGTH = 20 +PASSWORD_SYMBOLS = "!@#$%^&*()-_=+[]{}:,.?" + + +def _generate_password_candidate() -> str: + random = secrets.SystemRandom() + required_characters = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + secrets.choice(PASSWORD_SYMBOLS), + ] + alphabet = string.ascii_letters + string.digits + PASSWORD_SYMBOLS + remaining_characters = [ + secrets.choice(alphabet) + for _ in range(PASSWORD_GENERATION_LENGTH - len(required_characters)) + ] + characters = required_characters + remaining_characters + random.shuffle(characters) + return "".join(characters) def _validate_new_password(password: str, user: Users) -> None: @@ -63,9 +86,36 @@ def _validate_new_password(password: str, user: Users) -> None: user.hashed_password = get_password_hash(password) user.password_changed_at = datetime.now(timezone.utc) apply_password_evaluation(user, evaluation) - - -# define response models + + +@admin_router.post( + "/users/{id}/generate_password", + response_model=security.GeneratedPasswordResponse, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def generate_user_password(id: int, db: Session = Depends(get_db)): + user = db.scalars(select(Users).where(Users.id == id)).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + for _ in range(PASSWORD_GENERATION_ATTEMPTS): + password = _generate_password_candidate() + evaluation = evaluate_password( + password, + user=user, + include_compromised_check=True, + ) + if evaluation.is_policy_compliant and evaluation.compromised_count == 0: + return security.GeneratedPasswordResponse(password=password) + + raise HTTPException( + status_code=503, + detail="Unable to generate a verified uncompromised password. Please try again.", + ) + + +# define response models @admin_router.post( "/users/update_password", response_model=security.User, diff --git a/api/schemas/security.py b/api/schemas/security.py index c8cf0789..dc96f748 100644 --- a/api/schemas/security.py +++ b/api/schemas/security.py @@ -14,14 +14,18 @@ class UserRole(ORMBase): security_scopes: list[SecurityScope] | None = None -class UpdatedUserPassword(ORMBase): - user_id: int - new_password: str - - -class UpdatedUser(ORMBase): - id: int - username: str +class UpdatedUserPassword(ORMBase): + user_id: int + new_password: str + + +class GeneratedPasswordResponse(BaseModel): + password: str + + +class UpdatedUser(ORMBase): + id: int + username: str email: str full_name: str disabled: bool diff --git a/frontend/src/service/users.ts b/frontend/src/service/users.ts index af3a22c2..b43b56b3 100644 --- a/frontend/src/service/users.ts +++ b/frontend/src/service/users.ts @@ -282,3 +282,23 @@ export function useUpdateUserPassword(onSuccess: Function) { retry: 0, }); } + +export function useGenerateUserPassword() { + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async (userId: number): Promise<{ password: string }> => { + const response = await apiClient.post( + `users/${userId}/generate_password`, + undefined, + ); + + if (!response.ok) { + throw new Error(await getErrorMessage(response)); + } + + return response.json(); + }, + retry: 0, + }); +} diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index ae5f11ee..8c08abd2 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -1,23 +1,32 @@ -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useEffect, useState } from "react"; +import { Control, Resolver, useForm, useWatch } from "react-hook-form"; import { Accordion, AccordionDetails, AccordionSummary, Alert, + Box, Button, Card, CardContent, Grid, + IconButton, + InputAdornment, + LinearProgress, + Stack, Typography, } from "@mui/material"; import { Add, + AutoFixHigh, Edit, Save, SaveAs, LockReset, ExpandMore, + Visibility, + VisibilityOff, + ContentCopy, } from "@mui/icons-material"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; @@ -29,6 +38,7 @@ import { useGetRoles, useUpdateUserPassword, useGetUser, + useGenerateUserPassword, } from "@/service"; import { ControlledTextbox, @@ -39,7 +49,9 @@ import { import { UpdatedUserPassword, User, UserRole } from "@/interfaces"; import { evaluatePasswordLocally } from "@/utils"; -const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ +type UserFormValues = User; + +const UserResolverSchema = Yup.object().shape({ full_name: Yup.string().required("Please enter a full name."), display_name: Yup.string().required("Please enter a display name."), username: Yup.string().required("Please enter a username."), @@ -53,18 +65,50 @@ const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ ), }); -const formatSubmission = (user: User) => { - let formattedUser = user; +const formatSubmission = (user: UserFormValues) => { + const formattedUser = { ...user }; formattedUser.user_role_id = user.user_role?.id; delete formattedUser.user_role; return formattedUser; }; +const passwordRequirementLabels = [ + "Use at least 12 characters.", + "Add a lowercase letter.", + "Add an uppercase letter.", + "Add a number.", + "Add a symbol.", +]; + +const userResolver = yupResolver( + UserResolverSchema, +) as unknown as Resolver; + const SetNewPasswordAccordion = ({ control, errorMessage, handleSubmit, -}: any) => { + handleGeneratePassword, + handleCopyPassword, + isGeneratingPassword, +}: { + control: Control; + errorMessage?: string; + handleSubmit: () => void; + handleGeneratePassword: () => void; + handleCopyPassword: () => void; + isGeneratingPassword: boolean; +}) => { + const [showPassword, setShowPassword] = useState(false); + const password = useWatch({ control, name: "password" }) ?? ""; + const evaluation = evaluatePasswordLocally(password); + const strengthColor = + evaluation.score >= 5 + ? "success" + : evaluation.score >= 3 + ? "warning" + : "error"; + return ( - - + + + + setShowPassword((current) => !current)} + edge="end" + sx={{ mr: 1 }} + > + {showPassword ? : } + + + + + + ), + }} + /> + {password ? ( + + + Password strength: {evaluation.label} + + + + ) : null} + + {passwordRequirementLabels.map((requirement) => { + const isMissing = + !password || + evaluation.missing_requirements.includes(requirement); + return ( + + {isMissing ? "[ ]" : "[x]"} {requirement} + + ); + })} + + Passwords are checked against known compromised password lists + before they are saved. + + + - - + + + + + @@ -110,7 +227,9 @@ export const UserDetailsCard = ({ useEffect(() => { if (!userAddMode && userQuery.data) { reset(); - Object.entries(userQuery.data).forEach(([k, v]) => setValue(k as any, v)); + Object.entries(userQuery.data).forEach(([k, v]) => + setValue(k as keyof UserFormValues, v as never), + ); } if (userAddMode) reset(); }, [userAddMode, userQuery.data]); @@ -123,9 +242,8 @@ export const UserDetailsCard = ({ reset, watch, formState: { errors }, - } = useForm({ - resolver: yupResolver(UserResolverSchema), - }); + clearErrors, + } = useForm({ resolver: userResolver }); const onSuccessfulUpdate = () => enqueueSnackbar("Successfully Updated User!", { variant: "success" }); @@ -138,16 +256,17 @@ export const UserDetailsCard = ({ reset(); }; - const onErr = (data: any) => console.error("ERR: ", data); + const onErr = (data: unknown) => console.error("ERR: ", data); const updateUser = useUpdateUser(onSuccessfulUpdate); const createUser = useCreateUser(onSuccessfulCreate); const updateUserPassword = useUpdateUserPassword(onSuccessfulPasswordUpdate); + const generateUserPassword = useGenerateUserPassword(); - const onSaveChanges = (user: User) => + const onSaveChanges = (user: UserFormValues) => updateUser.mutate(formatSubmission(user)); - const onCreateUser = (user: User) => { + const onCreateUser = (user: UserFormValues) => { if (!user.password || user.password.length < 1) { enqueueSnackbar("Please provide a password.", { variant: "error" }); return; @@ -180,6 +299,51 @@ export const UserDetailsCard = ({ updateUserPassword.mutate(updatedUserPassword); }; + const onGeneratePassword = () => { + const selectedUserId = watch("id"); + if (!selectedUserId) { + enqueueSnackbar("Select a user before generating a password.", { + variant: "error", + }); + return; + } + + generateUserPassword.mutate(selectedUserId, { + onSuccess: ({ password }) => { + setValue("password", password, { + shouldDirty: true, + shouldValidate: true, + }); + clearErrors("password"); + enqueueSnackbar("Generated a new strong password.", { + variant: "success", + }); + }, + onError: (error) => { + const message = + error instanceof Error + ? error.message + : "Failed to generate password."; + enqueueSnackbar(message, { variant: "error" }); + }, + }); + }; + + const onCopyPassword = async () => { + const password = watch("password"); + if (!password) { + enqueueSnackbar("No password to copy.", { variant: "info" }); + return; + } + + try { + await navigator.clipboard.writeText(password); + enqueueSnackbar("Password copied.", { variant: "success" }); + } catch { + enqueueSnackbar("Unable to copy password.", { variant: "error" }); + } + }; + useEffect(() => { if (userAddMode) reset(); }, [userAddMode]); @@ -268,6 +432,9 @@ export const UserDetailsCard = ({ handleSubmit={() => onUpdateUserPassword(watch("id"), watch("password")) } + handleGeneratePassword={onGeneratePassword} + handleCopyPassword={onCopyPassword} + isGeneratingPassword={generateUserPassword.isLoading} /> )} From 6348bd91e10f25ef562c60acf1d0c608bdc8537f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 29 Jun 2026 17:03:05 -0500 Subject: [PATCH 05/21] fix(auth): update logic to close user sessions older than 14 days & re-logins on the same machine --- api/auth/session_tracking.py | 111 +++++++++++++++++++++++++++++++---- api/config.py | 3 + 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/api/auth/session_tracking.py b/api/auth/session_tracking.py index 92bbf8b6..ab8eb299 100644 --- a/api/auth/session_tracking.py +++ b/api/auth/session_tracking.py @@ -5,8 +5,10 @@ from uuid import uuid4 from fastapi import Request +from sqlalchemy import or_ from sqlalchemy.orm import Session +from api.config import settings from api.models.user import SignOutReasonTypeLU, UserSessions, Users LAST_SEEN_UPDATE_INTERVAL = timedelta(minutes=5) @@ -105,6 +107,87 @@ def build_device_label( return browser or operating_system or device_type +def close_user_session( + db: Session, + session: UserSessions, + reason_name: Optional[str], + signed_out_at: datetime, +) -> UserSessions: + if session.signed_out_at is not None: + return session + + sign_out_reason = get_sign_out_reason(db, reason_name) + + session.signed_out_at = signed_out_at + session.last_seen_at = signed_out_at + session.is_active = False + session.sign_out_reason_type_id = sign_out_reason.id if sign_out_reason else None + db.add(session) + + return session + + +def close_old_active_user_sessions( + db: Session, user: Users, signed_out_at: datetime +) -> None: + retention_days = max(settings.USER_SESSION_RETENTION_DAYS, 0) + cutoff = signed_out_at - timedelta(days=retention_days) + old_sessions = ( + db.query(UserSessions) + .filter( + UserSessions.user_id == user.id, + UserSessions.is_active.is_(True), + UserSessions.signed_out_at.is_(None), + or_( + UserSessions.signed_in_at < cutoff, + UserSessions.last_seen_at < cutoff, + ), + ) + .all() + ) + + for session in old_sessions: + close_user_session(db, session, "session_expired", signed_out_at) + + +def close_existing_machine_session( + db: Session, + user: Users, + signed_out_at: datetime, + user_agent: Optional[str], + device_label: Optional[str], + device_type: Optional[str], + browser: Optional[str], + operating_system: Optional[str], + fingerprint_hash: Optional[str], +) -> None: + query = db.query(UserSessions).filter( + UserSessions.user_id == user.id, + UserSessions.is_active.is_(True), + UserSessions.signed_out_at.is_(None), + ) + + if fingerprint_hash: + matching_sessions = query.filter( + UserSessions.fingerprint_hash == fingerprint_hash + ).all() + else: + if not any([user_agent, device_label, device_type, browser, operating_system]): + return + + matching_sessions = query.filter( + UserSessions.fingerprint_hash.is_(None), + UserSessions.user_agent == user_agent, + UserSessions.device_label == device_label, + UserSessions.device_type == device_type, + UserSessions.browser == browser, + UserSessions.operating_system == operating_system, + ).all() + + for session in matching_sessions: + close_user_session(db, session, "forced_logout", signed_out_at) + + def create_user_session(db: Session, user: Users, request: Request) -> UserSessions: user_agent = normalize_header_value(request.headers.get("user-agent")) browser = normalize_header_value(request.headers.get("x-browser")) or parse_browser( @@ -122,6 +205,20 @@ def create_user_session(db: Session, user: Users, request: Request) -> UserSessi fingerprint_hash = normalize_header_value( request.headers.get("x-device-fingerprint") ) + now = datetime.utcnow() + + close_old_active_user_sessions(db=db, user=user, signed_out_at=now) + close_existing_machine_session( + db=db, + user=user, + signed_out_at=now, + user_agent=user_agent, + device_label=device_label, + device_type=device_type, + browser=browser, + operating_system=operating_system, + fingerprint_hash=fingerprint_hash, + ) session = UserSessions( user_id=user.id, @@ -133,8 +230,8 @@ def create_user_session(db: Session, user: Users, request: Request) -> UserSessi browser=browser, operating_system=operating_system, fingerprint_hash=fingerprint_hash, - signed_in_at=datetime.utcnow(), - last_seen_at=datetime.utcnow(), + signed_in_at=now, + last_seen_at=now, is_active=True, ) @@ -188,15 +285,7 @@ def mark_session_signed_out( if session.signed_out_at is not None: return session - sign_out_reason = get_sign_out_reason(db, reason_name) - - session.signed_out_at = datetime.utcnow() - session.last_seen_at = session.signed_out_at - session.is_active = False - session.sign_out_reason_type_id = sign_out_reason.id if sign_out_reason else None - db.add(session) - - return session + return close_user_session(db, session, reason_name, datetime.utcnow()) def touch_user_session(db: Session, session_identifier: Optional[str]) -> None: diff --git a/api/config.py b/api/config.py index 4c1f1890..a6309493 100644 --- a/api/config.py +++ b/api/config.py @@ -30,6 +30,9 @@ class Settings: JWT_SECRET_KEY: Optional[str] = os.getenv("JWT_SECRET_KEY") JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256") ACCESS_TOKEN_EXPIRE_HOURS: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "8")) + USER_SESSION_RETENTION_DAYS: int = int( + os.getenv("USER_SESSION_RETENTION_DAYS", "14") + ) APP_ENV: str = os.getenv( "APP_ENV", "production" if os.getenv("APPDB_ENV") == ".env_production" else "development", From f8ee1dd4a839fba8e44a0f7f4f72842be263fdec Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 29 Jun 2026 21:24:30 -0500 Subject: [PATCH 06/21] feat(reports/soldmeters): init report --- api/routes/meters.py | 50 ++ api/services/meters.py | 144 ++++- api/templates/sold_meters_report.html | 87 +++ frontend/src/constants.ts | 8 + frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/reports/soldmeters.tsx | 35 ++ .../src/views/Reports/SoldMeters/index.tsx | 515 ++++++++++++++++++ frontend/src/views/Reports/index.tsx | 6 + 8 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 api/templates/sold_meters_report.html create mode 100644 frontend/src/routes/reports/soldmeters.tsx create mode 100644 frontend/src/views/Reports/SoldMeters/index.tsx diff --git a/api/routes/meters.py b/api/routes/meters.py index 9cd09a1b..c2ae0634 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -1,5 +1,7 @@ +from datetime import date from typing import List from fastapi import Depends, APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse from sqlalchemy import or_, select, desc, and_, text from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError @@ -105,6 +107,54 @@ def sort_by_field_to_schema_field(name: MeterSortByField): return paginate(db, query_statement) +@authenticated_meter_router.get( + "/meters/sold-report", + dependencies=[Depends(ScopedUser.Read)], + tags=["Meters"], +) +def get_sold_meters_report( + from_date: date = Query(..., description="Start date YYYY-MM-DD"), + to_date: date = Query(..., description="End date YYYY-MM-DD"), + min_size: int | None = Query(None, ge=0), + max_size: int | None = Query(None, ge=0), + db: Session = Depends(get_db), +): + return meter_service.get_sold_meters_report( + db, + from_date, + to_date, + min_size, + max_size, + ) + + +@authenticated_meter_router.get( + "/meters/sold-report/pdf", + dependencies=[Depends(ScopedUser.Read)], + tags=["Meters"], +) +def download_sold_meters_pdf( + from_date: date = Query(..., description="Start date YYYY-MM-DD"), + to_date: date = Query(..., description="End date YYYY-MM-DD"), + min_size: int | None = Query(None, ge=0), + max_size: int | None = Query(None, ge=0), + db: Session = Depends(get_db), +): + pdf_io = meter_service.build_sold_meters_pdf( + db, + from_date, + to_date, + min_size, + max_size, + ) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=sold_meters_report.pdf"}, + ) + + @authenticated_meter_router.post( "/meters", response_model=meter.Meter, diff --git a/api/services/meters.py b/api/services/meters.py index 61f22ce4..e1d6c4d5 100644 --- a/api/services/meters.py +++ b/api/services/meters.py @@ -1,14 +1,32 @@ from enum import Enum +from datetime import date, datetime +from io import BytesIO +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape from sqlalchemy import select from sqlalchemy.orm import Session, joinedload +from weasyprint import HTML -from api.models.meter import MeterActivities, MeterObservations +from api.models.meter import ( + ActivityTypeLU, + MeterActivities, + MeterObservations, + Meters, + MeterTypeLU, +) from api.models.part import Parts, PartsUsed from api.models.well import Wells from api.services.storage import create_signed_url +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]), +) + + class HistoryType(Enum): Activity = "Activity" Observation = "Observation" @@ -96,3 +114,127 @@ def get_meter_history(db: Session, meter_id: int): formatted_history_items.sort(key=lambda item: item["date"], reverse=True) return formatted_history_items + + +def _meter_type_label(meter_type: MeterTypeLU) -> str: + return " ".join( + filter( + None, + [ + meter_type.brand, + meter_type.series, + meter_type.model, + f'{meter_type.size:g}"', + ], + ) + ) + + +def get_sold_meters_report( + db: Session, + from_date: date, + to_date: date, + min_size: int | None = None, + max_size: int | None = None, +): + start_dt = datetime.combine(from_date, datetime.min.time()) + end_dt = datetime.combine(to_date, datetime.max.time()) + + stmt = ( + select(MeterActivities, Meters, MeterTypeLU) + .join(ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id) + .join(Meters, Meters.id == MeterActivities.meter_id) + .join(MeterTypeLU, MeterTypeLU.id == Meters.meter_type_id) + .where( + ActivityTypeLU.name == "Sell", + MeterActivities.timestamp_start >= start_dt, + MeterActivities.timestamp_start <= end_dt, + ) + .order_by(MeterActivities.timestamp_start.asc(), Meters.serial_number.asc()) + ) + + if min_size is not None: + stmt = stmt.where(MeterTypeLU.size >= min_size) + if max_size is not None: + stmt = stmt.where(MeterTypeLU.size <= max_size) + + rows = [] + type_totals_by_id = {} + total_value = 0.0 + + for activity, meter, meter_type in db.execute(stmt).all(): + price = float(meter.price or 0) + total_value += price + meter_type_label = _meter_type_label(meter_type) + + rows.append( + { + "id": activity.id, + "activity_id": activity.id, + "sold_date": activity.timestamp_start, + "serial_number": meter.serial_number, + "meter_owner": meter.meter_owner, + "contact_name": meter.contact_name, + "price": price, + "meter_type_id": meter_type.id, + "meter_type": meter_type_label, + "brand": meter_type.brand, + "series": meter_type.series, + "model": meter_type.model, + "size": meter_type.size, + "description": meter_type.description, + } + ) + + if meter_type.id not in type_totals_by_id: + type_totals_by_id[meter_type.id] = { + "id": meter_type.id, + "meter_type": meter_type_label, + "brand": meter_type.brand, + "series": meter_type.series, + "model": meter_type.model, + "size": meter_type.size, + "description": meter_type.description, + "quantity": 0, + "total_value": 0.0, + } + type_totals_by_id[meter_type.id]["quantity"] += 1 + type_totals_by_id[meter_type.id]["total_value"] += price + + type_totals = sorted( + type_totals_by_id.values(), + key=lambda row: (row["size"], row["meter_type"]), + ) + + return { + "rows": rows, + "summary": { + "quantity": len(rows), + "total_value": total_value, + }, + "type_totals": type_totals, + } + + +def build_sold_meters_pdf( + db: Session, + from_date: date, + to_date: date, + min_size: int | None = None, + max_size: int | None = None, +): + report = get_sold_meters_report(db, from_date, to_date, min_size, max_size) + + html_content = templates.get_template("sold_meters_report.html").render( + rows=report["rows"], + summary=report["summary"], + type_totals=report["type_totals"], + from_date=from_date, + to_date=to_date, + min_size=min_size, + max_size=max_size, + ) + pdf_io = BytesIO() + HTML(string=html_content).write_pdf(pdf_io) + pdf_io.seek(0) + return pdf_io diff --git a/api/templates/sold_meters_report.html b/api/templates/sold_meters_report.html new file mode 100644 index 00000000..687321f5 --- /dev/null +++ b/api/templates/sold_meters_report.html @@ -0,0 +1,87 @@ + + + + + Sold Meters Report + + + +

Sold Meters Report

+
+ {{ from_date.strftime("%Y-%m-%d") }} to {{ to_date.strftime("%Y-%m-%d") }} + {% if min_size is not none or max_size is not none %} + | Meter size: + {{ min_size if min_size is not none else "Any" }} + to + {{ max_size if max_size is not none else "Any" }} + {% endif %} +
+ +
+
Total meters sold{{ summary.quantity }}
+
Total value${{ "%.2f"|format(summary.total_value) }}
+
+ +

Meter Type Totals

+ + + + + + + + + + + {% for row in type_totals %} + + + + + + + {% endfor %} + +
Meter TypeSizeQuantity SoldTotal Value
{{ row.meter_type }}{{ "%g"|format(row.size) }}{{ row.quantity }}${{ "%.2f"|format(row.total_value) }}
+ +

Sold Meters

+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + +
Sold DateSerial NumberMeter TypeSizeContactOwnerValue
{{ row.sold_date.strftime("%Y-%m-%d") }}{{ row.serial_number }}{{ row.meter_type }}{{ "%g"|format(row.size) }}{{ row.contact_name or "" }}{{ row.meter_owner or "" }}${{ "%.2f"|format(row.price) }}
+ + diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 410b1916..761c8b94 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -12,6 +12,7 @@ import { WaterDrop, SpeedOutlined, Engineering, + SellOutlined, } from "@mui/icons-material"; import { SvgIconProps } from "@mui/material"; import { ComponentType } from "react"; @@ -86,6 +87,13 @@ export const navConfig: NavItem[] = [ role: "Technician", parent: "reports", }, + { + path: "/reports/soldmeters", + label: "Sold Meters", + icon: SellOutlined, + role: "Technician", + parent: "reports", + }, // Admin { path: "/manage/parts", label: "Manage Parts", icon: Build, role: "Admin" }, diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 8e4d6621..3e205fa9 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ActivitiesRouteImport } from './routes/activities' import { Route as IndexRouteImport } from './routes/index' import { Route as ReportsIndexRouteImport } from './routes/reports/index' import { Route as ManageIndexRouteImport } from './routes/manage/index' +import { Route as ReportsSoldmetersRouteImport } from './routes/reports/soldmeters' import { Route as ReportsPartsusedRouteImport } from './routes/reports/partsused' import { Route as ReportsMonitoringwellsRouteImport } from './routes/reports/monitoringwells' import { Route as ReportsMaintenanceRouteImport } from './routes/reports/maintenance' @@ -83,6 +84,11 @@ const ManageIndexRoute = ManageIndexRouteImport.update({ path: '/manage/', getParentRoute: () => rootRouteImport, } as any) +const ReportsSoldmetersRoute = ReportsSoldmetersRouteImport.update({ + id: '/reports/soldmeters', + path: '/reports/soldmeters', + getParentRoute: () => rootRouteImport, +} as any) const ReportsPartsusedRoute = ReportsPartsusedRouteImport.update({ id: '/reports/partsused', path: '/reports/partsused', @@ -169,6 +175,7 @@ export interface FileRoutesByFullPath { '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute + '/reports/soldmeters': typeof ReportsSoldmetersRoute '/manage/': typeof ManageIndexRoute '/reports/': typeof ReportsIndexRoute '/manage/parts/': typeof ManagePartsIndexRoute @@ -193,6 +200,7 @@ export interface FileRoutesByTo { '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute + '/reports/soldmeters': typeof ReportsSoldmetersRoute '/manage': typeof ManageIndexRoute '/reports': typeof ReportsIndexRoute '/manage/parts': typeof ManagePartsIndexRoute @@ -219,6 +227,7 @@ export interface FileRoutesById { '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute + '/reports/soldmeters': typeof ReportsSoldmetersRoute '/manage/': typeof ManageIndexRoute '/reports/': typeof ReportsIndexRoute '/manage/parts/': typeof ManagePartsIndexRoute @@ -246,6 +255,7 @@ export interface FileRouteTypes { | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/reports/soldmeters' | '/manage/' | '/reports/' | '/manage/parts/' @@ -270,6 +280,7 @@ export interface FileRouteTypes { | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/reports/soldmeters' | '/manage' | '/reports' | '/manage/parts' @@ -295,6 +306,7 @@ export interface FileRouteTypes { | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/reports/soldmeters' | '/manage/' | '/reports/' | '/manage/parts/' @@ -321,6 +333,7 @@ export interface RootRouteChildren { ReportsMaintenanceRoute: typeof ReportsMaintenanceRoute ReportsMonitoringwellsRoute: typeof ReportsMonitoringwellsRoute ReportsPartsusedRoute: typeof ReportsPartsusedRoute + ReportsSoldmetersRoute: typeof ReportsSoldmetersRoute ManageIndexRoute: typeof ManageIndexRoute ReportsIndexRoute: typeof ReportsIndexRoute } @@ -397,6 +410,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ManageIndexRouteImport parentRoute: typeof rootRouteImport } + '/reports/soldmeters': { + id: '/reports/soldmeters' + path: '/reports/soldmeters' + fullPath: '/reports/soldmeters' + preLoaderRoute: typeof ReportsSoldmetersRouteImport + parentRoute: typeof rootRouteImport + } '/reports/partsused': { id: '/reports/partsused' path: '/reports/partsused' @@ -537,6 +557,7 @@ const rootRouteChildren: RootRouteChildren = { ReportsMaintenanceRoute: ReportsMaintenanceRoute, ReportsMonitoringwellsRoute: ReportsMonitoringwellsRoute, ReportsPartsusedRoute: ReportsPartsusedRoute, + ReportsSoldmetersRoute: ReportsSoldmetersRoute, ManageIndexRoute: ManageIndexRoute, ReportsIndexRoute: ReportsIndexRoute, } diff --git a/frontend/src/routes/reports/soldmeters.tsx b/frontend/src/routes/reports/soldmeters.tsx new file mode 100644 index 00000000..ef6dd011 --- /dev/null +++ b/frontend/src/routes/reports/soldmeters.tsx @@ -0,0 +1,35 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import dayjs from "dayjs"; +import { SoldMetersReportView } from "@/views/Reports/SoldMeters"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + dayjsDateParam, + optionalNonNegativeInt, + pageParam, + routeSearchHydrator, +} from "@/utils"; + +const searchSchema = z.object({ + from: dayjsDateParam + .catch(dayjs().startOf("month").format("YYYY-MM-DD")) + .default(dayjs().startOf("month").format("YYYY-MM-DD")), + to: dayjsDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + min_size: optionalNonNegativeInt, + max_size: optionalNonNegativeInt, + page: pageParam(0, 0, 1000), + pageSize: pageParam(10, 5, 100), +}); + +export const Route = createFileRoute("/reports/soldmeters")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/views/Reports/SoldMeters/index.tsx b/frontend/src/views/Reports/SoldMeters/index.tsx new file mode 100644 index 00000000..a582c19d --- /dev/null +++ b/frontend/src/views/Reports/SoldMeters/index.tsx @@ -0,0 +1,515 @@ +import { useEffect, useMemo } from "react"; +import { useAuthHeader } from "react-auth-kit"; +import { PictureAsPdf, SellOutlined } from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardContent, + Grid, + Skeleton, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { BarChart } from "@mui/x-charts"; +import { useNavigate } from "@tanstack/react-router"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery } from "react-query"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import dayjs, { Dayjs } from "dayjs"; + +import { API_URL } from "@/config"; +import { + BackgroundBox, + ControlledDatepicker, + CustomCardHeader, + ReportBreadcrumbTitle, +} from "@/components"; +import { Route } from "@/routes/reports/soldmeters"; + +type SoldMeterRow = { + id: number; + activity_id: number; + sold_date: string; + serial_number: string; + meter_owner: string | null; + contact_name: string | null; + price: number; + meter_type_id: number; + meter_type: string; + brand: string; + series: string | null; + model: string; + size: number; + description: string; +}; + +type MeterTypeTotal = { + id: number; + meter_type: string; + size: number; + quantity: number; + total_value: number; +}; + +type SoldMetersReport = { + rows: SoldMeterRow[]; + summary: { + quantity: number; + total_value: number; + }; + type_totals: MeterTypeTotal[]; +}; + +type FormValues = { + from: Dayjs; + to: Dayjs; + min_size?: number | null; + max_size?: number | null; +}; + +const schema = yup.object().shape({ + from: yup.mixed().nullable().required("From date is required"), + to: yup + .mixed() + .nullable() + .required("To date is required") + .test("is-after", "'To' date must be on or after 'From'", function (value) { + const { from } = this.parent; + return !from || !value || !dayjs(value).isBefore(dayjs(from), "day"); + }), + min_size: yup.number().nullable().min(0).integer(), + max_size: yup + .number() + .nullable() + .min(0) + .integer() + .test("is-at-least-min", "Max size must be at least min size", function (value) { + const { min_size } = this.parent; + return value == null || min_size == null || value >= min_size; + }), +}); + +const formatCurrency = (value: number | null | undefined) => + `$${(value ?? 0).toFixed(2)}`; + +const defaultDateSearch = { + from: dayjs().startOf("month").format("YYYY-MM-DD"), + to: dayjs().endOf("month").format("YYYY-MM-DD"), +}; + +export const SoldMetersReportView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); + const authHeader = useAuthHeader(); + + const defaultValues = useMemo( + () => ({ + from: dayjs(search.from, "YYYY-MM-DD"), + to: dayjs(search.to, "YYYY-MM-DD"), + min_size: search.min_size ?? null, + max_size: search.max_size ?? null, + }), + [search.from, search.to, search.min_size, search.max_size], + ); + + const { control, reset, watch } = useForm({ + resolver: yupResolver(schema), + defaultValues, + }); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const from = watch("from"); + const to = watch("to"); + const minSize = watch("min_size"); + const maxSize = watch("max_size"); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/soldmeters", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + useEffect(() => { + const nextFrom = from?.format("YYYY-MM-DD"); + const nextTo = to?.format("YYYY-MM-DD"); + const nextMinSize = minSize ?? undefined; + const nextMaxSize = maxSize ?? undefined; + + setSearch((prev) => { + if ( + prev.from === nextFrom && + prev.to === nextTo && + prev.min_size === nextMinSize && + prev.max_size === nextMaxSize + ) { + return prev; + } + + return { + ...prev, + from: nextFrom, + to: nextTo, + min_size: nextMinSize, + max_size: nextMaxSize, + page: 0, + }; + }); + }, [from, to, minSize, maxSize]); + + const reportQuery = useQuery({ + queryKey: ["Meters", "report", "soldmeters", search], + queryFn: async () => { + const params = new URLSearchParams({ + from_date: search.from, + to_date: search.to, + }); + + if (search.min_size != null) { + params.set("min_size", search.min_size.toString()); + } + if (search.max_size != null) { + params.set("max_size", search.max_size.toString()); + } + + const response = await fetch( + `${API_URL}/meters/sold-report?${params.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to fetch sold meters report"); + } + + return response.json(); + }, + enabled: Boolean(search.from && search.to), + }); + + const downloadPDFMutation = useMutation({ + mutationFn: async () => { + const params = new URLSearchParams({ + from_date: search.from, + to_date: search.to, + }); + + if (search.min_size != null) { + params.set("min_size", search.min_size.toString()); + } + if (search.max_size != null) { + params.set("max_size", search.max_size.toString()); + } + + const response = await fetch( + `${API_URL}/meters/sold-report/pdf?${params.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("PDF generation failed"); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "sold_meters_report.pdf"; + a.click(); + window.URL.revokeObjectURL(url); + }, + }); + + const rows = reportQuery.data?.rows ?? []; + const typeTotals = reportQuery.data?.type_totals ?? []; + const summary = reportQuery.data?.summary ?? { quantity: 0, total_value: 0 }; + + const columns: GridColDef[] = [ + { + field: "sold_date", + headerName: "Sold Date", + flex: 1, + minWidth: 120, + valueFormatter: (value: string) => dayjs(value).format("YYYY-MM-DD"), + }, + { field: "serial_number", headerName: "Serial Number", flex: 1, minWidth: 140 }, + { field: "meter_type", headerName: "Meter Type", flex: 1.6, minWidth: 220 }, + { field: "size", headerName: "Size", flex: 0.6, minWidth: 80, type: "number" }, + { + field: "price", + headerName: "Value", + flex: 0.8, + minWidth: 110, + type: "number", + valueFormatter: (value: number) => formatCurrency(value), + }, + { field: "contact_name", headerName: "Contact", flex: 1, minWidth: 140 }, + { field: "meter_owner", headerName: "Owner", flex: 1, minWidth: 120 }, + ]; + + const typeTotalColumns: GridColDef[] = [ + { field: "meter_type", headerName: "Meter Type", flex: 1.5, minWidth: 180 }, + { field: "size", headerName: "Size", flex: 0.5, minWidth: 80, type: "number" }, + { + field: "quantity", + headerName: "Sold", + flex: 0.5, + minWidth: 80, + type: "number", + }, + { + field: "total_value", + headerName: "Total Value", + flex: 0.8, + minWidth: 120, + type: "number", + valueFormatter: (value: number) => formatCurrency(value), + }, + ]; + + return ( + + + } + icon={SellOutlined} + /> + + + + + + + + + + ( + + field.onChange( + event.target.value === "" + ? null + : Number(event.target.value), + ) + } + fullWidth + size="small" + type="number" + label="Min Size" + error={!!fieldState.error} + helperText={fieldState.error?.message} + inputProps={{ min: 0, step: 1 }} + /> + )} + /> + + + ( + + field.onChange( + event.target.value === "" + ? null + : Number(event.target.value), + ) + } + fullWidth + size="small" + type="number" + label="Max Size" + error={!!fieldState.error} + helperText={fieldState.error?.message} + inputProps={{ min: 0, step: 1 }} + /> + )} + /> + + + + + + + + + + + + + + + Total Meters Sold + + + {summary.quantity} + + + + + + + Total Value + + + {formatCurrency(summary.total_value)} + + + + + + + Meter Types Sold + + + {typeTotals.length} + + + + + + + + Meter Type Totals + + + + {reportQuery.isLoading ? ( + + ) : typeTotals.length ? ( + row.meter_type), + }, + ]} + series={[ + { + data: typeTotals.map((row) => row.quantity), + label: "Meters Sold", + }, + ]} + /> + ) : ( + + No sold meters found. + + )} + + + + + + + + + + setSearch((prev) => ({ + ...prev, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, + })) + } + /> + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index 68c0476d..4717c34a 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -4,6 +4,7 @@ import { ConstructionOutlined, MonitorHeartOutlined, ScienceOutlined, + SellOutlined, } from "@mui/icons-material"; import { Box, Card, CardContent } from "@mui/material"; import { BackgroundBox, CustomCardHeader, NavLink } from "@/components"; @@ -35,6 +36,11 @@ export const ReportsView = () => { label="Parts Used" icon={BuildOutlined} /> +
From f94f6ddae3f548cce840f2333f608373c19df8a8 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 29 Jun 2026 21:33:54 -0500 Subject: [PATCH 07/21] feat(reports/installedmeters): init report page --- api/routes/meters.py | 50 ++ api/services/meters.py | 116 ++++ api/templates/installed_meters_report.html | 89 +++ frontend/src/constants.ts | 8 + frontend/src/routeTree.gen.ts | 21 + .../src/routes/reports/installedmeters.tsx | 35 ++ .../views/Reports/InstalledMeters/index.tsx | 511 ++++++++++++++++++ frontend/src/views/Reports/index.tsx | 6 + 8 files changed, 836 insertions(+) create mode 100644 api/templates/installed_meters_report.html create mode 100644 frontend/src/routes/reports/installedmeters.tsx create mode 100644 frontend/src/views/Reports/InstalledMeters/index.tsx diff --git a/api/routes/meters.py b/api/routes/meters.py index c2ae0634..918c31d5 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -155,6 +155,56 @@ def download_sold_meters_pdf( ) +@authenticated_meter_router.get( + "/meters/installed-report", + dependencies=[Depends(ScopedUser.Read)], + tags=["Meters"], +) +def get_installed_meters_report( + from_date: date = Query(..., description="Start date YYYY-MM-DD"), + to_date: date = Query(..., description="End date YYYY-MM-DD"), + min_size: int | None = Query(None, ge=0), + max_size: int | None = Query(None, ge=0), + db: Session = Depends(get_db), +): + return meter_service.get_installed_meters_report( + db, + from_date, + to_date, + min_size, + max_size, + ) + + +@authenticated_meter_router.get( + "/meters/installed-report/pdf", + dependencies=[Depends(ScopedUser.Read)], + tags=["Meters"], +) +def download_installed_meters_pdf( + from_date: date = Query(..., description="Start date YYYY-MM-DD"), + to_date: date = Query(..., description="End date YYYY-MM-DD"), + min_size: int | None = Query(None, ge=0), + max_size: int | None = Query(None, ge=0), + db: Session = Depends(get_db), +): + pdf_io = meter_service.build_installed_meters_pdf( + db, + from_date, + to_date, + min_size, + max_size, + ) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=installed_meters_report.pdf" + }, + ) + + @authenticated_meter_router.post( "/meters", response_model=meter.Meter, diff --git a/api/services/meters.py b/api/services/meters.py index e1d6c4d5..ebae97ce 100644 --- a/api/services/meters.py +++ b/api/services/meters.py @@ -15,6 +15,7 @@ Meters, MeterTypeLU, ) +from api.models.location import Locations from api.models.part import Parts, PartsUsed from api.models.well import Wells from api.services.storage import create_signed_url @@ -216,6 +217,97 @@ def get_sold_meters_report( } +def get_installed_meters_report( + db: Session, + from_date: date, + to_date: date, + min_size: int | None = None, + max_size: int | None = None, +): + start_dt = datetime.combine(from_date, datetime.min.time()) + end_dt = datetime.combine(to_date, datetime.max.time()) + + stmt = ( + select(MeterActivities, Meters, MeterTypeLU, Locations, Wells) + .join(ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id) + .join(Meters, Meters.id == MeterActivities.meter_id) + .join(MeterTypeLU, MeterTypeLU.id == Meters.meter_type_id) + .join(Locations, Locations.id == MeterActivities.location_id, isouter=True) + .join(Wells, Wells.location_id == MeterActivities.location_id, isouter=True) + .where( + ActivityTypeLU.name == "Install", + MeterActivities.timestamp_start >= start_dt, + MeterActivities.timestamp_start <= end_dt, + ) + .order_by(MeterActivities.timestamp_start.asc(), Meters.serial_number.asc()) + ) + + if min_size is not None: + stmt = stmt.where(MeterTypeLU.size >= min_size) + if max_size is not None: + stmt = stmt.where(MeterTypeLU.size <= max_size) + + rows = [] + type_totals_by_id = {} + total_value = 0.0 + + for activity, meter, meter_type, location, well in db.execute(stmt).all(): + price = float(meter.price or 0) + total_value += price + meter_type_label = _meter_type_label(meter_type) + + rows.append( + { + "id": activity.id, + "activity_id": activity.id, + "installed_date": activity.timestamp_start, + "serial_number": meter.serial_number, + "meter_owner": meter.meter_owner, + "contact_name": meter.contact_name, + "water_users": activity.water_users, + "well_ra_number": well.ra_number if well else None, + "trss": location.trss if location else None, + "price": price, + "meter_type_id": meter_type.id, + "meter_type": meter_type_label, + "brand": meter_type.brand, + "series": meter_type.series, + "model": meter_type.model, + "size": meter_type.size, + "description": meter_type.description, + } + ) + + if meter_type.id not in type_totals_by_id: + type_totals_by_id[meter_type.id] = { + "id": meter_type.id, + "meter_type": meter_type_label, + "brand": meter_type.brand, + "series": meter_type.series, + "model": meter_type.model, + "size": meter_type.size, + "description": meter_type.description, + "quantity": 0, + "total_value": 0.0, + } + type_totals_by_id[meter_type.id]["quantity"] += 1 + type_totals_by_id[meter_type.id]["total_value"] += price + + type_totals = sorted( + type_totals_by_id.values(), + key=lambda row: (row["size"], row["meter_type"]), + ) + + return { + "rows": rows, + "summary": { + "quantity": len(rows), + "total_value": total_value, + }, + "type_totals": type_totals, + } + + def build_sold_meters_pdf( db: Session, from_date: date, @@ -238,3 +330,27 @@ def build_sold_meters_pdf( HTML(string=html_content).write_pdf(pdf_io) pdf_io.seek(0) return pdf_io + + +def build_installed_meters_pdf( + db: Session, + from_date: date, + to_date: date, + min_size: int | None = None, + max_size: int | None = None, +): + report = get_installed_meters_report(db, from_date, to_date, min_size, max_size) + + html_content = templates.get_template("installed_meters_report.html").render( + rows=report["rows"], + summary=report["summary"], + type_totals=report["type_totals"], + from_date=from_date, + to_date=to_date, + min_size=min_size, + max_size=max_size, + ) + pdf_io = BytesIO() + HTML(string=html_content).write_pdf(pdf_io) + pdf_io.seek(0) + return pdf_io diff --git a/api/templates/installed_meters_report.html b/api/templates/installed_meters_report.html new file mode 100644 index 00000000..73656784 --- /dev/null +++ b/api/templates/installed_meters_report.html @@ -0,0 +1,89 @@ + + + + + Installed Meters Report + + + +

Installed Meters Report

+
+ {{ from_date.strftime("%Y-%m-%d") }} to {{ to_date.strftime("%Y-%m-%d") }} + {% if min_size is not none or max_size is not none %} + | Meter size: + {{ min_size if min_size is not none else "Any" }} + to + {{ max_size if max_size is not none else "Any" }} + {% endif %} +
+ +
+
Total meters installed{{ summary.quantity }}
+
Total value${{ "%.2f"|format(summary.total_value) }}
+
+ +

Meter Type Totals

+ + + + + + + + + + + {% for row in type_totals %} + + + + + + + {% endfor %} + +
Meter TypeSizeQuantity InstalledTotal Value
{{ row.meter_type }}{{ "%g"|format(row.size) }}{{ row.quantity }}${{ "%.2f"|format(row.total_value) }}
+ +

Installed Meters

+ + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + {% endfor %} + +
Installed DateSerial NumberMeter TypeSizeRA NumberTRSSWater UsersValue
{{ row.installed_date.strftime("%Y-%m-%d") }}{{ row.serial_number }}{{ row.meter_type }}{{ "%g"|format(row.size) }}{{ row.well_ra_number or "" }}{{ row.trss or "" }}{{ row.water_users or "" }}${{ "%.2f"|format(row.price) }}
+ + diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 761c8b94..b06bc76e 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -12,6 +12,7 @@ import { WaterDrop, SpeedOutlined, Engineering, + EngineeringOutlined, SellOutlined, } from "@mui/icons-material"; import { SvgIconProps } from "@mui/material"; @@ -87,6 +88,13 @@ export const navConfig: NavItem[] = [ role: "Technician", parent: "reports", }, + { + path: "/reports/installedmeters", + label: "Installed Meters", + icon: EngineeringOutlined, + role: "Technician", + parent: "reports", + }, { path: "/reports/soldmeters", label: "Sold Meters", diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 3e205fa9..54d5ce38 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as ReportsSoldmetersRouteImport } from './routes/reports/soldmete import { Route as ReportsPartsusedRouteImport } from './routes/reports/partsused' import { Route as ReportsMonitoringwellsRouteImport } from './routes/reports/monitoringwells' import { Route as ReportsMaintenanceRouteImport } from './routes/reports/maintenance' +import { Route as ReportsInstalledmetersRouteImport } from './routes/reports/installedmeters' import { Route as ReportsChloridesRouteImport } from './routes/reports/chlorides' import { Route as ManageWellsRouteImport } from './routes/manage/wells' import { Route as ManageUsersRouteImport } from './routes/manage/users' @@ -104,6 +105,11 @@ const ReportsMaintenanceRoute = ReportsMaintenanceRouteImport.update({ path: '/reports/maintenance', getParentRoute: () => rootRouteImport, } as any) +const ReportsInstalledmetersRoute = ReportsInstalledmetersRouteImport.update({ + id: '/reports/installedmeters', + path: '/reports/installedmeters', + getParentRoute: () => rootRouteImport, +} as any) const ReportsChloridesRoute = ReportsChloridesRouteImport.update({ id: '/reports/chlorides', path: '/reports/chlorides', @@ -172,6 +178,7 @@ export interface FileRoutesByFullPath { '/manage/users': typeof ManageUsersRoute '/manage/wells': typeof ManageWellsRoute '/reports/chlorides': typeof ReportsChloridesRoute + '/reports/installedmeters': typeof ReportsInstalledmetersRoute '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute @@ -197,6 +204,7 @@ export interface FileRoutesByTo { '/manage/users': typeof ManageUsersRoute '/manage/wells': typeof ManageWellsRoute '/reports/chlorides': typeof ReportsChloridesRoute + '/reports/installedmeters': typeof ReportsInstalledmetersRoute '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute @@ -224,6 +232,7 @@ export interface FileRoutesById { '/manage/users': typeof ManageUsersRoute '/manage/wells': typeof ManageWellsRoute '/reports/chlorides': typeof ReportsChloridesRoute + '/reports/installedmeters': typeof ReportsInstalledmetersRoute '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute @@ -252,6 +261,7 @@ export interface FileRouteTypes { | '/manage/users' | '/manage/wells' | '/reports/chlorides' + | '/reports/installedmeters' | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' @@ -277,6 +287,7 @@ export interface FileRouteTypes { | '/manage/users' | '/manage/wells' | '/reports/chlorides' + | '/reports/installedmeters' | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' @@ -303,6 +314,7 @@ export interface FileRouteTypes { | '/manage/users' | '/manage/wells' | '/reports/chlorides' + | '/reports/installedmeters' | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' @@ -330,6 +342,7 @@ export interface RootRouteChildren { ManageUsersRoute: typeof ManageUsersRoute ManageWellsRoute: typeof ManageWellsRoute ReportsChloridesRoute: typeof ReportsChloridesRoute + ReportsInstalledmetersRoute: typeof ReportsInstalledmetersRoute ReportsMaintenanceRoute: typeof ReportsMaintenanceRoute ReportsMonitoringwellsRoute: typeof ReportsMonitoringwellsRoute ReportsPartsusedRoute: typeof ReportsPartsusedRoute @@ -438,6 +451,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReportsMaintenanceRouteImport parentRoute: typeof rootRouteImport } + '/reports/installedmeters': { + id: '/reports/installedmeters' + path: '/reports/installedmeters' + fullPath: '/reports/installedmeters' + preLoaderRoute: typeof ReportsInstalledmetersRouteImport + parentRoute: typeof rootRouteImport + } '/reports/chlorides': { id: '/reports/chlorides' path: '/reports/chlorides' @@ -554,6 +574,7 @@ const rootRouteChildren: RootRouteChildren = { ManageUsersRoute: ManageUsersRoute, ManageWellsRoute: ManageWellsRoute, ReportsChloridesRoute: ReportsChloridesRoute, + ReportsInstalledmetersRoute: ReportsInstalledmetersRoute, ReportsMaintenanceRoute: ReportsMaintenanceRoute, ReportsMonitoringwellsRoute: ReportsMonitoringwellsRoute, ReportsPartsusedRoute: ReportsPartsusedRoute, diff --git a/frontend/src/routes/reports/installedmeters.tsx b/frontend/src/routes/reports/installedmeters.tsx new file mode 100644 index 00000000..792479b6 --- /dev/null +++ b/frontend/src/routes/reports/installedmeters.tsx @@ -0,0 +1,35 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import dayjs from "dayjs"; +import { InstalledMetersReportView } from "@/views/Reports/InstalledMeters"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + dayjsDateParam, + optionalNonNegativeInt, + pageParam, + routeSearchHydrator, +} from "@/utils"; + +const searchSchema = z.object({ + from: dayjsDateParam + .catch(dayjs().startOf("month").format("YYYY-MM-DD")) + .default(dayjs().startOf("month").format("YYYY-MM-DD")), + to: dayjsDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + min_size: optionalNonNegativeInt, + max_size: optionalNonNegativeInt, + page: pageParam(0, 0, 1000), + pageSize: pageParam(10, 5, 100), +}); + +export const Route = createFileRoute("/reports/installedmeters")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/views/Reports/InstalledMeters/index.tsx b/frontend/src/views/Reports/InstalledMeters/index.tsx new file mode 100644 index 00000000..5d7004b5 --- /dev/null +++ b/frontend/src/views/Reports/InstalledMeters/index.tsx @@ -0,0 +1,511 @@ +import { useEffect, useMemo } from "react"; +import { useAuthHeader } from "react-auth-kit"; +import { EngineeringOutlined, PictureAsPdf } from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardContent, + Grid, + Skeleton, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { BarChart } from "@mui/x-charts"; +import { useNavigate } from "@tanstack/react-router"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery } from "react-query"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import dayjs, { Dayjs } from "dayjs"; + +import { API_URL } from "@/config"; +import { + BackgroundBox, + ControlledDatepicker, + CustomCardHeader, + ReportBreadcrumbTitle, +} from "@/components"; +import { Route } from "@/routes/reports/installedmeters"; + +type InstalledMeterRow = { + id: number; + activity_id: number; + installed_date: string; + serial_number: string; + meter_owner: string | null; + contact_name: string | null; + water_users: string | null; + well_ra_number: string | null; + trss: string | null; + price: number; + meter_type_id: number; + meter_type: string; + brand: string; + series: string | null; + model: string; + size: number; + description: string; +}; + +type MeterTypeTotal = { + id: number; + meter_type: string; + size: number; + quantity: number; + total_value: number; +}; + +type InstalledMetersReport = { + rows: InstalledMeterRow[]; + summary: { + quantity: number; + total_value: number; + }; + type_totals: MeterTypeTotal[]; +}; + +type FormValues = { + from: Dayjs; + to: Dayjs; + min_size?: number | null; + max_size?: number | null; +}; + +const schema = yup.object().shape({ + from: yup.mixed().nullable().required("From date is required"), + to: yup + .mixed() + .nullable() + .required("To date is required") + .test("is-after", "'To' date must be on or after 'From'", function (value) { + const { from } = this.parent; + return !from || !value || !dayjs(value).isBefore(dayjs(from), "day"); + }), + min_size: yup.number().nullable().min(0).integer(), + max_size: yup + .number() + .nullable() + .min(0) + .integer() + .test("is-at-least-min", "Max size must be at least min size", function (value) { + const { min_size } = this.parent; + return value == null || min_size == null || value >= min_size; + }), +}); + +const formatCurrency = (value: number | null | undefined) => + `$${(value ?? 0).toFixed(2)}`; + +const defaultDateSearch = { + from: dayjs().startOf("month").format("YYYY-MM-DD"), + to: dayjs().endOf("month").format("YYYY-MM-DD"), +}; + +export const InstalledMetersReportView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); + const authHeader = useAuthHeader(); + + const defaultValues = useMemo( + () => ({ + from: dayjs(search.from, "YYYY-MM-DD"), + to: dayjs(search.to, "YYYY-MM-DD"), + min_size: search.min_size ?? null, + max_size: search.max_size ?? null, + }), + [search.from, search.to, search.min_size, search.max_size], + ); + + const { control, reset, watch } = useForm({ + resolver: yupResolver(schema), + defaultValues, + }); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const from = watch("from"); + const to = watch("to"); + const minSize = watch("min_size"); + const maxSize = watch("max_size"); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/installedmeters", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + useEffect(() => { + const nextFrom = from?.format("YYYY-MM-DD"); + const nextTo = to?.format("YYYY-MM-DD"); + const nextMinSize = minSize ?? undefined; + const nextMaxSize = maxSize ?? undefined; + + setSearch((prev) => { + if ( + prev.from === nextFrom && + prev.to === nextTo && + prev.min_size === nextMinSize && + prev.max_size === nextMaxSize + ) { + return prev; + } + + return { + ...prev, + from: nextFrom, + to: nextTo, + min_size: nextMinSize, + max_size: nextMaxSize, + page: 0, + }; + }); + }, [from, to, minSize, maxSize]); + + const buildParams = () => { + const params = new URLSearchParams({ + from_date: search.from, + to_date: search.to, + }); + + if (search.min_size != null) { + params.set("min_size", search.min_size.toString()); + } + if (search.max_size != null) { + params.set("max_size", search.max_size.toString()); + } + + return params; + }; + + const reportQuery = useQuery({ + queryKey: ["Meters", "report", "installedmeters", search], + queryFn: async () => { + const response = await fetch( + `${API_URL}/meters/installed-report?${buildParams().toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to fetch installed meters report"); + } + + return response.json(); + }, + enabled: Boolean(search.from && search.to), + }); + + const downloadPDFMutation = useMutation({ + mutationFn: async () => { + const response = await fetch( + `${API_URL}/meters/installed-report/pdf?${buildParams().toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("PDF generation failed"); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "installed_meters_report.pdf"; + a.click(); + window.URL.revokeObjectURL(url); + }, + }); + + const rows = reportQuery.data?.rows ?? []; + const typeTotals = reportQuery.data?.type_totals ?? []; + const summary = reportQuery.data?.summary ?? { quantity: 0, total_value: 0 }; + + const columns: GridColDef[] = [ + { + field: "installed_date", + headerName: "Installed Date", + flex: 1, + minWidth: 130, + valueFormatter: (value: string) => dayjs(value).format("YYYY-MM-DD"), + }, + { field: "serial_number", headerName: "Serial Number", flex: 1, minWidth: 140 }, + { field: "meter_type", headerName: "Meter Type", flex: 1.6, minWidth: 220 }, + { field: "size", headerName: "Size", flex: 0.6, minWidth: 80, type: "number" }, + { field: "well_ra_number", headerName: "RA Number", flex: 0.8, minWidth: 120 }, + { field: "trss", headerName: "TRSS", flex: 0.8, minWidth: 120 }, + { field: "water_users", headerName: "Water Users", flex: 1.2, minWidth: 160 }, + { + field: "price", + headerName: "Value", + flex: 0.8, + minWidth: 110, + type: "number", + valueFormatter: (value: number) => formatCurrency(value), + }, + ]; + + const typeTotalColumns: GridColDef[] = [ + { field: "meter_type", headerName: "Meter Type", flex: 1.5, minWidth: 180 }, + { field: "size", headerName: "Size", flex: 0.5, minWidth: 80, type: "number" }, + { + field: "quantity", + headerName: "Installed", + flex: 0.5, + minWidth: 90, + type: "number", + }, + { + field: "total_value", + headerName: "Total Value", + flex: 0.8, + minWidth: 120, + type: "number", + valueFormatter: (value: number) => formatCurrency(value), + }, + ]; + + return ( + + + } + icon={EngineeringOutlined} + /> + + + + + + + + + + ( + + field.onChange( + event.target.value === "" + ? null + : Number(event.target.value), + ) + } + fullWidth + size="small" + type="number" + label="Min Size" + error={!!fieldState.error} + helperText={fieldState.error?.message} + inputProps={{ min: 0, step: 1 }} + /> + )} + /> + + + ( + + field.onChange( + event.target.value === "" + ? null + : Number(event.target.value), + ) + } + fullWidth + size="small" + type="number" + label="Max Size" + error={!!fieldState.error} + helperText={fieldState.error?.message} + inputProps={{ min: 0, step: 1 }} + /> + )} + /> + + + + + + + + + + + + + + + Total Meters Installed + + + {summary.quantity} + + + + + + + Total Value + + + {formatCurrency(summary.total_value)} + + + + + + + Meter Types Installed + + + {typeTotals.length} + + + + + + + + Meter Type Totals + + + + {reportQuery.isLoading ? ( + + ) : typeTotals.length ? ( + row.meter_type), + }, + ]} + series={[ + { + data: typeTotals.map((row) => row.quantity), + label: "Meters Installed", + }, + ]} + /> + ) : ( + + No installed meters found. + + )} + + + + + + + + + + setSearch((prev) => ({ + ...prev, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, + })) + } + /> + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index 4717c34a..1d0dbf8c 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -2,6 +2,7 @@ import { AssessmentOutlined, BuildOutlined, ConstructionOutlined, + EngineeringOutlined, MonitorHeartOutlined, ScienceOutlined, SellOutlined, @@ -36,6 +37,11 @@ export const ReportsView = () => { label="Parts Used" icon={BuildOutlined} /> + Date: Wed, 8 Jul 2026 14:58:52 -0500 Subject: [PATCH 08/21] chore(package): update pkgs --- frontend/package-lock.json | 186 +++++++++++++++++++------------------ 1 file changed, 98 insertions(+), 88 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4da8c933..e253da78 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -62,12 +62,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -76,9 +76,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -86,21 +86,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -124,13 +124,13 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -140,14 +140,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -157,37 +157,37 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -207,27 +207,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -235,26 +235,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -305,31 +305,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -337,13 +337,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -7400,9 +7400,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10222,13 +10232,13 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", From c4c726c60dcae00c122861e5571fbe14698e7fba Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 8 Jul 2026 17:21:48 -0500 Subject: [PATCH 09/21] feat(auth): init services account to the app --- .gitignore | 4 + api/auth/dependencies.py | 3 + api/main.py | 2 +- api/models/__init__.py | 2 + api/models/user.py | 30 +++ api/routes/admin.py | 248 +++++++++++++++++- api/routes/work_orders.py | 5 +- api/schemas/security.py | 50 +++- api/security.py | 77 +++++- frontend/.gitignore | 3 + ...260711000000_add_service_accounts.down.sql | 11 + ...20260711000000_add_service_accounts.up.sql | 39 +++ ...work_order_service_account_scopes.down.sql | 28 ++ ...d_work_order_service_account_scopes.up.sql | 38 +++ 14 files changed, 518 insertions(+), 22 deletions(-) create mode 100644 migrations/20260711000000_add_service_accounts.down.sql create mode 100644 migrations/20260711000000_add_service_accounts.up.sql create mode 100644 migrations/20260711000100_add_work_order_service_account_scopes.down.sql create mode 100644 migrations/20260711000100_add_work_order_service_account_scopes.up.sql diff --git a/.gitignore b/.gitignore index f768459b..92cbdfe3 100644 --- a/.gitignore +++ b/.gitignore @@ -229,3 +229,7 @@ cython_debug/ # dependencies /node_modules /frontend/node_modules + +# Mise Envs +mise.toml + diff --git a/api/auth/dependencies.py b/api/auth/dependencies.py index a0848c83..8acbc863 100644 --- a/api/auth/dependencies.py +++ b/api/auth/dependencies.py @@ -11,3 +11,6 @@ class ScopedUser(Enum): WellMeasurementWrite = scoped_user(["well_measurement:write"]) MeterWrite = scoped_user(["meters:write"]) WellWrite = scoped_user(["well:write"]) + WorkOrderRead = scoped_user(["work_orders:read"]) + WorkOrderCreate = scoped_user(["work_orders:create"]) + WorkOrderUpdate = scoped_user(["work_orders:update"]) diff --git a/api/main.py b/api/main.py index 8637186e..a2668964 100644 --- a/api/main.py +++ b/api/main.py @@ -170,7 +170,6 @@ async def update_user_session_last_seen(request: Request, call_next): authenticated_router.include_router(authenticated_meter_router) authenticated_router.include_router(notifications_router) authenticated_router.include_router(part_router) -authenticated_router.include_router(work_orders_router) authenticated_router.include_router(authenticated_well_measurement_router) authenticated_router.include_router(authenticated_well_router) authenticated_router.include_router(settings_router) @@ -185,4 +184,5 @@ async def update_user_session_last_seen(request: Request, call_next): app.include_router(public_maintenance_router) app.include_router(public_well_measurement_router) app.include_router(user_sessions_router) +app.include_router(work_orders_router) app.include_router(authenticated_router) diff --git a/api/models/__init__.py b/api/models/__init__.py index ff65bc7f..8a4cd1dd 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -23,6 +23,7 @@ Notifications, ScopesRoles, SecurityScopes, + ServiceAccountApiKeys, SignOutReasonTypeLU, UserRoles, UserSessions, @@ -56,6 +57,7 @@ "PropertyUnits", "ScopesRoles", "SecurityScopes", + "ServiceAccountApiKeys", "ServiceTypeLU", "ServicesPerformed", "SignOutReasonTypeLU", diff --git a/api/models/user.py b/api/models/user.py index 48e8971e..e24f04c8 100644 --- a/api/models/user.py +++ b/api/models/user.py @@ -11,6 +11,9 @@ class Users(Base): full_name: Mapped[str] = mapped_column(String) disabled: Mapped[bool] = mapped_column(Boolean, default=False) + is_service_account: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) username: Mapped[str] = deferred(mapped_column(String, nullable=False)) email: Mapped[str] = deferred(mapped_column(String)) hashed_password: Mapped[str] = deferred(mapped_column(String, nullable=False)) @@ -52,6 +55,33 @@ class Users(Base): back_populates="user", cascade="all, delete-orphan", ) + service_account_api_keys: Mapped[List["ServiceAccountApiKeys"]] = relationship( + "ServiceAccountApiKeys", + back_populates="user", + cascade="all, delete-orphan", + ) + + +class ServiceAccountApiKeys(Base): + __tablename__ = "service_account_api_keys" + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="CASCADE", onupdate="CASCADE"), index=True + ) + key_identifier: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, index=True + ) + key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + key_prefix: Mapped[str] = mapped_column(String(32), nullable=False) + created_at: Mapped[DateTime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), index=True + ) + last_used_at: Mapped[Optional[DateTime]] = mapped_column(DateTime, index=True) + revoked_at: Mapped[Optional[DateTime]] = mapped_column(DateTime, index=True) + + user: Mapped["Users"] = relationship( + "Users", back_populates="service_account_api_keys" + ) class SignOutReasonTypeLU(Base): diff --git a/api/routes/admin.py b/api/routes/admin.py index 5d163200..aacadbd6 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -8,7 +8,7 @@ from sqlalchemy import select from typing import List -from api.models.user import Users, UserRoles, SecurityScopes +from api.models.user import ServiceAccountApiKeys, Users, UserRoles, SecurityScopes from api.schemas import security from api.schemas import admin @@ -19,6 +19,7 @@ from api.auth.password_policy import apply_password_evaluation, evaluate_password from api.security import ( create_access_token, + create_service_account_api_key, ACCESS_TOKEN_EXPIRE_HOURS, get_password_hash, ) @@ -88,6 +89,46 @@ def _validate_new_password(password: str, user: Users) -> None: apply_password_evaluation(user, evaluation) +def _serialize_service_account( + service_account: Users, + api_key: str | None = None, +) -> security.ServiceAccount | security.ServiceAccountWithKey: + payload = { + "id": service_account.id, + "username": service_account.username, + "email": service_account.email, + "full_name": service_account.full_name, + "disabled": service_account.disabled, + "user_role_id": service_account.user_role_id, + "user_role": service_account.user_role, + "display_name": service_account.display_name, + "redirect_page": service_account.redirect_page, + "avatar_img": service_account.avatar_img, + "password_changed_at": service_account.password_changed_at, + "password_strength_score": service_account.password_strength_score, + "password_strength_label": service_account.password_strength_label, + "password_policy_compliant": service_account.password_policy_compliant, + "password_compromised_checked_at": service_account.password_compromised_checked_at, + "password_compromised_count": service_account.password_compromised_count, + "is_service_account": service_account.is_service_account, + "api_keys": service_account.service_account_api_keys, + } + if api_key is not None: + payload["api_key"] = api_key + return security.ServiceAccountWithKey(**payload) + return security.ServiceAccount(**payload) + + +def _service_account_query(): + return select(Users).options( + undefer(Users.username), + undefer(Users.email), + undefer(Users.user_role_id), + joinedload(Users.user_role).joinedload(UserRoles.security_scopes), + joinedload(Users.service_account_api_keys), + ) + + @admin_router.post( "/users/{id}/generate_password", response_model=security.GeneratedPasswordResponse, @@ -98,6 +139,11 @@ def generate_user_password(id: int, db: Session = Depends(get_db)): user = db.scalars(select(Users).where(Users.id == id)).first() if not user: raise HTTPException(status_code=404, detail="User not found") + if user.is_service_account: + raise HTTPException( + status_code=400, + detail="Service accounts do not use passwords.", + ) for _ in range(PASSWORD_GENERATION_ATTEMPTS): password = _generate_password_candidate() @@ -131,6 +177,11 @@ def update_user_password( ).first() if not user: raise HTTPException(status_code=404, detail="User not found") + if user.is_service_account: + raise HTTPException( + status_code=400, + detail="Service accounts do not use passwords.", + ) _validate_new_password(updatedUserPassword.new_password, user) db.commit() @@ -145,10 +196,17 @@ def update_user_password( dependencies=[Depends(ScopedUser.Admin)], tags=["Admin"], ) -def update_user( +def update_user( updated_user: security.UpdatedUser, db: Session = Depends(get_db) -): - _patch(db, Users, updated_user.id, updated_user) +): + user = db.scalars(select(Users).where(Users.id == updated_user.id)).first() + if user and user.is_service_account: + raise HTTPException( + status_code=400, + detail="Use service account endpoints to update service accounts.", + ) + + _patch(db, Users, updated_user.id, updated_user) qualified_user = db.scalars( select(Users) @@ -205,7 +263,7 @@ def create_user(user: security.NewUser, db: Session = Depends(get_db)): dependencies=[Depends(ScopedUser.Admin)], tags=["Admin"], ) -def get_user_admin(id: int, db: Session = Depends(get_db)): +def get_user_admin(id: int, db: Session = Depends(get_db)): """ Admin-specific single user endpoint (includes username/email/role) """ @@ -217,8 +275,9 @@ def get_user_admin(id: int, db: Session = Depends(get_db)): undefer(Users.email), joinedload(Users.user_role), ) - .where(Users.id == id) - ).first() + .where(Users.id == id) + .where(Users.is_service_account.is_(False)) + ).first() if not user: raise HTTPException(status_code=404, detail="User not found") @@ -243,13 +302,181 @@ def get_users_admin(db: Session = Depends(get_db)): undefer(Users.user_role_id), undefer(Users.email), joinedload(Users.user_role), - ) - ) + ).where(Users.is_service_account.is_(False)) + ) .unique() .all() ) +@admin_router.get( + "/service-accounts", + response_model=List[security.ServiceAccount], + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def get_service_accounts(db: Session = Depends(get_db)): + service_accounts = ( + db.scalars( + _service_account_query() + .where(Users.is_service_account.is_(True)) + .order_by(Users.username) + ) + .unique() + .all() + ) + return [ + _serialize_service_account(service_account) + for service_account in service_accounts + ] + + +@admin_router.post( + "/service-accounts", + response_model=security.ServiceAccountWithKey, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def create_service_account( + service_account: security.NewServiceAccount, + db: Session = Depends(get_db), +): + existing_user = db.scalars( + select(Users).where(Users.username == service_account.username) + ).first() + if existing_user: + raise HTTPException(status_code=409, detail="Username already exists") + + role = db.scalars( + select(UserRoles).where(UserRoles.id == service_account.user_role_id) + ).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + new_service_account = Users( + username=service_account.username, + email=None, + full_name=service_account.full_name, + display_name=service_account.display_name, + user_role_id=service_account.user_role_id, + disabled=service_account.disabled, + is_service_account=True, + hashed_password=get_password_hash(secrets.token_urlsafe(32)), + ) + db.add(new_service_account) + db.flush() + _, api_key = create_service_account_api_key(db, new_service_account) + db.commit() + + qualified_service_account = ( + db.scalars(_service_account_query().where(Users.id == new_service_account.id)) + .unique() + .first() + ) + return _serialize_service_account(qualified_service_account, api_key=api_key) + + +@admin_router.patch( + "/service-accounts/{id}", + response_model=security.ServiceAccount, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def update_service_account( + id: int, + updated_service_account: security.UpdatedServiceAccount, + db: Session = Depends(get_db), +): + service_account = db.scalars( + select(Users).where( + Users.id == id, + Users.is_service_account.is_(True), + ) + ).first() + if not service_account: + raise HTTPException(status_code=404, detail="Service account not found") + + if updated_service_account.user_role_id is not None: + role = db.scalars( + select(UserRoles).where(UserRoles.id == updated_service_account.user_role_id) + ).first() + if not role: + raise HTTPException(status_code=404, detail="Role not found") + service_account.user_role_id = updated_service_account.user_role_id + if updated_service_account.full_name is not None: + service_account.full_name = updated_service_account.full_name + if updated_service_account.display_name is not None: + service_account.display_name = updated_service_account.display_name + if updated_service_account.disabled is not None: + service_account.disabled = updated_service_account.disabled + + db.commit() + qualified_service_account = ( + db.scalars(_service_account_query().where(Users.id == service_account.id)) + .unique() + .first() + ) + return _serialize_service_account(qualified_service_account) + + +@admin_router.post( + "/service-accounts/{id}/keys", + response_model=security.ServiceAccountWithKey, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def create_service_account_key(id: int, db: Session = Depends(get_db)): + service_account = db.scalars( + select(Users).where( + Users.id == id, + Users.is_service_account.is_(True), + ) + ).first() + if not service_account: + raise HTTPException(status_code=404, detail="Service account not found") + + _, api_key = create_service_account_api_key(db, service_account) + db.commit() + qualified_service_account = ( + db.scalars(_service_account_query().where(Users.id == service_account.id)) + .unique() + .first() + ) + return _serialize_service_account(qualified_service_account, api_key=api_key) + + +@admin_router.delete( + "/service-accounts/{id}/keys/{key_identifier}", + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def revoke_service_account_key( + id: int, + key_identifier: str, + db: Session = Depends(get_db), +): + api_key = ( + db.scalars( + select(ServiceAccountApiKeys) + .join(ServiceAccountApiKeys.user) + .where( + ServiceAccountApiKeys.key_identifier == key_identifier, + ServiceAccountApiKeys.user_id == id, + Users.is_service_account.is_(True), + ) + ) + .unique() + .first() + ) + if not api_key: + raise HTTPException(status_code=404, detail="Service account key not found") + + api_key.revoked_at = datetime.now(timezone.utc) + db.add(api_key) + db.commit() + return {"status": "revoked"} + + @admin_router.post( "/users/{id}/impersonate", response_model=security.Token, @@ -277,6 +504,7 @@ def impersonate_user( joinedload(Users.user_role).joinedload(UserRoles.security_scopes), ) .where(Users.id == id) + .where(Users.is_service_account.is_(False)) ).first() if not target_user: @@ -284,6 +512,8 @@ def impersonate_user( if target_user.disabled: raise HTTPException(status_code=400, detail="Cannot impersonate a disabled user") + if target_user.is_service_account: + raise HTTPException(status_code=400, detail="Cannot impersonate a service account") user_session = create_user_session(db=db, user=target_user, request=request) diff --git a/api/routes/work_orders.py b/api/routes/work_orders.py index bf174fdd..bddbbbaf 100644 --- a/api/routes/work_orders.py +++ b/api/routes/work_orders.py @@ -18,7 +18,7 @@ @work_orders_router.get( "/work_orders", - dependencies=[Depends(ScopedUser.Read)], + dependencies=[Depends(ScopedUser.WorkOrderRead)], tags=["Work Orders"], ) def get_work_orders( @@ -43,7 +43,7 @@ def get_work_orders( @work_orders_router.post( "/work_orders", - dependencies=[Depends(ScopedUser.Admin)], + dependencies=[Depends(ScopedUser.WorkOrderCreate)], response_model=meter.WorkOrder, tags=["Work Orders"], ) @@ -59,6 +59,7 @@ def create_work_order( @work_orders_router.patch( "/work_orders", + dependencies=[Depends(ScopedUser.WorkOrderUpdate)], response_model=meter.WorkOrder, tags=["Work Orders"], ) diff --git a/api/schemas/security.py b/api/schemas/security.py index dc96f748..2ea38ffa 100644 --- a/api/schemas/security.py +++ b/api/schemas/security.py @@ -1,7 +1,7 @@ from datetime import datetime from api.schemas.base import ORMBase -from pydantic import BaseModel +from pydantic import BaseModel, Field class SecurityScope(ORMBase): @@ -29,19 +29,42 @@ class UpdatedUser(ORMBase): email: str full_name: str disabled: bool - user_role_id: int - - -class NewUser(ORMBase): + user_role_id: int + + +class UpdatedServiceAccount(ORMBase): + full_name: str | None = None + display_name: str | None = None + disabled: bool | None = None + user_role_id: int | None = None + + +class NewUser(ORMBase): username: str email: str full_name: str display_name: str disabled: bool - user_role_id: int - password: str - - + user_role_id: int + password: str + + +class NewServiceAccount(ORMBase): + username: str + full_name: str + display_name: str | None = None + user_role_id: int + disabled: bool = False + + +class ServiceAccountApiKey(ORMBase): + key_identifier: str + key_prefix: str + created_at: datetime + last_used_at: datetime | None = None + revoked_at: datetime | None = None + + class User(ORMBase): username: str | None = None email: str | None = None @@ -63,6 +86,15 @@ class User(ORMBase): password_compromised_count: int | None = None +class ServiceAccount(User): + is_service_account: bool + api_keys: list[ServiceAccountApiKey] = Field(default_factory=list) + + +class ServiceAccountWithKey(ServiceAccount): + api_key: str + + class ImpersonationContext(BaseModel): impersonator_user_id: int impersonator_full_name: str | None = None diff --git a/api/security.py b/api/security.py index 819e1651..be8c056f 100644 --- a/api/security.py +++ b/api/security.py @@ -1,4 +1,7 @@ from datetime import timedelta, datetime +import hashlib +import hmac +import secrets from typing import Union, Annotated from fastapi import HTTPException, Depends, APIRouter, Security @@ -10,7 +13,12 @@ from sqlalchemy.orm import joinedload, undefer, Session from sqlalchemy.sql import select -from api.models.user import Users, UserRoles, SecurityScopes, UserSessions +from api.models.user import ( + ServiceAccountApiKeys, + Users, + UserRoles, + UserSessions, +) from api.schemas import security as security_schema from api.config import settings from api.session import get_db @@ -21,6 +29,7 @@ SECRET_KEY = settings.JWT_SECRET_KEY ALGORITHM = settings.JWT_ALGORITHM ACCESS_TOKEN_EXPIRE_HOURS = settings.ACCESS_TOKEN_EXPIRE_HOURS +SERVICE_ACCOUNT_KEY_PREFIX = "wmdb_sa" if not SECRET_KEY: raise RuntimeError("JWT_SECRET_KEY environment variable must be set.") @@ -55,6 +64,8 @@ def authenticate_user(login_identifier: str, password: str, db: Session): user = get_user_by_login(login_identifier, db) if not user: return False + if user.is_service_account: + return False if not verify_password(password, user.hashed_password): return False return user @@ -115,10 +126,74 @@ def get_user(username: str, db: Session) -> Users: return dbuser +def _hash_service_account_key(api_key: str) -> str: + return hashlib.sha256(api_key.encode("utf-8")).hexdigest() + + +def create_service_account_api_key(db: Session, user: Users) -> tuple[ServiceAccountApiKeys, str]: + key_identifier = secrets.token_urlsafe(12).replace("-", "").replace("_", "")[:16] + secret = secrets.token_urlsafe(32) + api_key = f"{SERVICE_ACCOUNT_KEY_PREFIX}_{key_identifier}_{secret}" + key = ServiceAccountApiKeys( + user_id=user.id, + key_identifier=key_identifier, + key_hash=_hash_service_account_key(api_key), + key_prefix=api_key[:24], + ) + db.add(key) + return key, api_key + + +def get_user_by_service_account_key(api_key: str, db: Session) -> Users | None: + parts = api_key.split("_", 3) + if len(parts) != 4 or "_".join(parts[:2]) != SERVICE_ACCOUNT_KEY_PREFIX: + return None + + key_identifier = parts[2] + key = ( + db.scalars( + select(ServiceAccountApiKeys) + .options( + joinedload(ServiceAccountApiKeys.user).options( + undefer(Users.username), + undefer(Users.user_role_id), + undefer(Users.email), + joinedload(Users.user_role).joinedload(UserRoles.security_scopes), + ), + ) + .where( + ServiceAccountApiKeys.key_identifier == key_identifier, + ServiceAccountApiKeys.revoked_at.is_(None), + ) + ) + .unique() + .first() + ) + + if key is None: + return None + + if not hmac.compare_digest(key.key_hash, _hash_service_account_key(api_key)): + return None + + user = key.user + if user is None or not user.is_service_account or user.disabled: + return None + + key.last_used_at = datetime.utcnow() + db.add(key) + db.commit() + return user + + def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[Session, Depends(get_db)], ) -> Users: + service_account_user = get_user_by_service_account_key(token, db) + if service_account_user: + return service_account_user + try: payload = decode_access_token(token) diff --git a/frontend/.gitignore b/frontend/.gitignore index 77607de1..83f3388a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -28,3 +28,6 @@ dist-ssr # But keep .env.example and .env.*.example !.env.example !.env.*.example + +# Mise Envs +mise.toml diff --git a/migrations/20260711000000_add_service_accounts.down.sql b/migrations/20260711000000_add_service_accounts.down.sql new file mode 100644 index 00000000..f1fa23ab --- /dev/null +++ b/migrations/20260711000000_add_service_accounts.down.sql @@ -0,0 +1,11 @@ +DROP INDEX IF EXISTS public.ix_service_account_api_keys_revoked_at; +DROP INDEX IF EXISTS public.ix_service_account_api_keys_last_used_at; +DROP INDEX IF EXISTS public.ix_service_account_api_keys_created_at; +DROP INDEX IF EXISTS public.ix_service_account_api_keys_key_identifier; +DROP INDEX IF EXISTS public.ix_service_account_api_keys_user_id; +DROP INDEX IF EXISTS public.ix_service_account_api_keys_id; + +DROP TABLE IF EXISTS public.service_account_api_keys; + +ALTER TABLE public."Users" +DROP COLUMN IF EXISTS is_service_account; diff --git a/migrations/20260711000000_add_service_accounts.up.sql b/migrations/20260711000000_add_service_accounts.up.sql new file mode 100644 index 00000000..c29c1b57 --- /dev/null +++ b/migrations/20260711000000_add_service_accounts.up.sql @@ -0,0 +1,39 @@ +ALTER TABLE public."Users" +ADD COLUMN IF NOT EXISTS is_service_account bool NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS public.service_account_api_keys ( + id serial4 NOT NULL, + user_id int4 NOT NULL, + key_identifier varchar(32) NOT NULL, + key_hash varchar(64) NOT NULL, + key_prefix varchar(32) NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + last_used_at timestamp NULL, + revoked_at timestamp NULL, + CONSTRAINT service_account_api_keys_pkey PRIMARY KEY (id), + CONSTRAINT service_account_api_keys_key_identifier_key UNIQUE (key_identifier), + CONSTRAINT service_account_api_keys_key_hash_key UNIQUE (key_hash), + CONSTRAINT fk_service_account_api_keys_user + FOREIGN KEY (user_id) + REFERENCES public."Users"(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS ix_service_account_api_keys_id + ON public.service_account_api_keys USING btree (id); + +CREATE INDEX IF NOT EXISTS ix_service_account_api_keys_user_id + ON public.service_account_api_keys USING btree (user_id); + +CREATE INDEX IF NOT EXISTS ix_service_account_api_keys_key_identifier + ON public.service_account_api_keys USING btree (key_identifier); + +CREATE INDEX IF NOT EXISTS ix_service_account_api_keys_created_at + ON public.service_account_api_keys USING btree (created_at); + +CREATE INDEX IF NOT EXISTS ix_service_account_api_keys_last_used_at + ON public.service_account_api_keys USING btree (last_used_at); + +CREATE INDEX IF NOT EXISTS ix_service_account_api_keys_revoked_at + ON public.service_account_api_keys USING btree (revoked_at); diff --git a/migrations/20260711000100_add_work_order_service_account_scopes.down.sql b/migrations/20260711000100_add_work_order_service_account_scopes.down.sql new file mode 100644 index 00000000..3a303820 --- /dev/null +++ b/migrations/20260711000100_add_work_order_service_account_scopes.down.sql @@ -0,0 +1,28 @@ +DELETE FROM public."ScopesRoles" +WHERE id IN ('17', '18', '19', '20', '21', '22', '23'); + +DELETE FROM public."UserRoles" +WHERE id = '4' + AND name = 'Service Account - Work Order Sync' + AND NOT EXISTS ( + SELECT 1 + FROM public."Users" + WHERE user_role_id = 4 + ); + +DELETE FROM public."SecurityScopes" +WHERE id IN ('9', '10', '11') + AND scope_string IN ( + 'work_orders:read', + 'work_orders:create', + 'work_orders:update' + ); + +SELECT setval('public."ScopesRoles_id_seq"', COALESCE(MAX(id), 1), TRUE) +FROM public."ScopesRoles"; + +SELECT setval('public."UserRoles_id_seq"', COALESCE(MAX(id), 1), TRUE) +FROM public."UserRoles"; + +SELECT setval('public."SecurityScopes_id_seq"', COALESCE(MAX(id), 1), TRUE) +FROM public."SecurityScopes"; diff --git a/migrations/20260711000100_add_work_order_service_account_scopes.up.sql b/migrations/20260711000100_add_work_order_service_account_scopes.up.sql new file mode 100644 index 00000000..518dde22 --- /dev/null +++ b/migrations/20260711000100_add_work_order_service_account_scopes.up.sql @@ -0,0 +1,38 @@ +WITH inserted_rows AS ( + INSERT INTO public."SecurityScopes" + (id, scope_string, description) + VALUES + ('9', 'work_orders:read', 'Read work orders.'), + ('10', 'work_orders:create', 'Create work orders.'), + ('11', 'work_orders:update', 'Update work orders.') + ON CONFLICT (id) DO NOTHING + RETURNING id +) +SELECT setval('public."SecurityScopes_id_seq"', COALESCE(MAX(id), 1), TRUE) +FROM public."SecurityScopes"; + +WITH inserted_rows AS ( + INSERT INTO public."UserRoles" + (id, name) + VALUES + ('4', 'Service Account - Work Order Sync') + ON CONFLICT (id) DO NOTHING + RETURNING id +) +SELECT setval('public."UserRoles_id_seq"', COALESCE(MAX(id), 1), TRUE) +FROM public."UserRoles"; + +INSERT INTO public."ScopesRoles" + (security_scope_id, user_role_id, id) +VALUES + ('9', '2', '17'), + ('10', '2', '18'), + ('11', '2', '19'), + ('9', '1', '20'), + ('11', '1', '21'), + ('9', '4', '22'), + ('10', '4', '23') +ON CONFLICT (id) DO NOTHING; + +SELECT setval('public."ScopesRoles_id_seq"', COALESCE(MAX(id), 1), TRUE) +FROM public."ScopesRoles"; From 18dd6eb0580b87a47aff45f9ed1e718108395a45 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 11 Jul 2026 16:57:06 -0500 Subject: [PATCH 10/21] feat(manage/serviceaccounts): init admin management pg --- frontend/src/constants.ts | 7 + frontend/src/interfaces/ServiceAccount.ts | 26 ++ frontend/src/interfaces/index.ts | 1 + frontend/src/routeTree.gen.ts | 21 + .../src/routes/manage/serviceaccounts.tsx | 31 ++ frontend/src/service/users.ts | 142 +++++++ .../ServiceAccountDetailsCard.tsx | 398 ++++++++++++++++++ .../ServiceAccountManagementView.tsx | 50 +++ .../UserManagement/ServiceAccountsTable.tsx | 198 +++++++++ frontend/src/views/UserManagement/index.ts | 1 + 10 files changed, 875 insertions(+) create mode 100644 frontend/src/interfaces/ServiceAccount.ts create mode 100644 frontend/src/routes/manage/serviceaccounts.tsx create mode 100644 frontend/src/views/UserManagement/ServiceAccountDetailsCard.tsx create mode 100644 frontend/src/views/UserManagement/ServiceAccountManagementView.tsx create mode 100644 frontend/src/views/UserManagement/ServiceAccountsTable.tsx diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index b06bc76e..4951edf2 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -5,6 +5,7 @@ import { Science, People, Storage, + ManageAccounts, MonitorHeartOutlined, ConstructionOutlined, BuildOutlined, @@ -106,6 +107,12 @@ export const navConfig: NavItem[] = [ // Admin { path: "/manage/parts", label: "Manage Parts", icon: Build, role: "Admin" }, { path: "/manage/users", label: "Manage Users", icon: People, role: "Admin" }, + { + path: "/manage/serviceaccounts", + label: "Service Accounts", + icon: ManageAccounts, + role: "Admin", + }, { path: "/manage/backups", label: "Manage Backups", diff --git a/frontend/src/interfaces/ServiceAccount.ts b/frontend/src/interfaces/ServiceAccount.ts new file mode 100644 index 00000000..56f8670c --- /dev/null +++ b/frontend/src/interfaces/ServiceAccount.ts @@ -0,0 +1,26 @@ +import type { User } from "./User"; + +export interface ServiceAccountApiKey { + id?: number; + key_identifier: string; + key_prefix: string; + created_at: string; + last_used_at?: string | null; + revoked_at?: string | null; +} + +export interface ServiceAccount extends User { + is_service_account: boolean; + api_keys: ServiceAccountApiKey[]; + api_key?: string; +} + +export interface ServiceAccountForm { + id?: number; + username?: string; + full_name: string; + display_name?: string | null; + disabled: boolean; + user_role_id?: number; + user_role?: ServiceAccount["user_role"]; +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 97d715f4..9a33d070 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -62,6 +62,7 @@ export * from "./ST2WaterLevelQueryParams"; export * from "./SecurityScope"; export * from "./SensorAttributes"; export * from "./SensorData"; +export * from "./ServiceAccount"; export * from "./ServiceTypeLU"; export * from "./SubmitWellCreate"; export * from "./Unit"; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 54d5ce38..667b83e6 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as ReportsInstalledmetersRouteImport } from './routes/reports/ins import { Route as ReportsChloridesRouteImport } from './routes/reports/chlorides' import { Route as ManageWellsRouteImport } from './routes/manage/wells' import { Route as ManageUsersRouteImport } from './routes/manage/users' +import { Route as ManageServiceaccountsRouteImport } from './routes/manage/serviceaccounts' import { Route as ManagePartsRouteImport } from './routes/manage/parts' import { Route as ManageMetersRouteImport } from './routes/manage/meters' import { Route as ManageBackupsRouteImport } from './routes/manage/backups' @@ -125,6 +126,11 @@ const ManageUsersRoute = ManageUsersRouteImport.update({ path: '/manage/users', getParentRoute: () => rootRouteImport, } as any) +const ManageServiceaccountsRoute = ManageServiceaccountsRouteImport.update({ + id: '/manage/serviceaccounts', + path: '/manage/serviceaccounts', + getParentRoute: () => rootRouteImport, +} as any) const ManagePartsRoute = ManagePartsRouteImport.update({ id: '/manage/parts', path: '/manage/parts', @@ -175,6 +181,7 @@ export interface FileRoutesByFullPath { '/manage/backups': typeof ManageBackupsRoute '/manage/meters': typeof ManageMetersRoute '/manage/parts': typeof ManagePartsRouteWithChildren + '/manage/serviceaccounts': typeof ManageServiceaccountsRoute '/manage/users': typeof ManageUsersRoute '/manage/wells': typeof ManageWellsRoute '/reports/chlorides': typeof ReportsChloridesRoute @@ -201,6 +208,7 @@ export interface FileRoutesByTo { '/internal/error-preview': typeof InternalErrorPreviewRoute '/manage/backups': typeof ManageBackupsRoute '/manage/meters': typeof ManageMetersRoute + '/manage/serviceaccounts': typeof ManageServiceaccountsRoute '/manage/users': typeof ManageUsersRoute '/manage/wells': typeof ManageWellsRoute '/reports/chlorides': typeof ReportsChloridesRoute @@ -229,6 +237,7 @@ export interface FileRoutesById { '/manage/backups': typeof ManageBackupsRoute '/manage/meters': typeof ManageMetersRoute '/manage/parts': typeof ManagePartsRouteWithChildren + '/manage/serviceaccounts': typeof ManageServiceaccountsRoute '/manage/users': typeof ManageUsersRoute '/manage/wells': typeof ManageWellsRoute '/reports/chlorides': typeof ReportsChloridesRoute @@ -258,6 +267,7 @@ export interface FileRouteTypes { | '/manage/backups' | '/manage/meters' | '/manage/parts' + | '/manage/serviceaccounts' | '/manage/users' | '/manage/wells' | '/reports/chlorides' @@ -284,6 +294,7 @@ export interface FileRouteTypes { | '/internal/error-preview' | '/manage/backups' | '/manage/meters' + | '/manage/serviceaccounts' | '/manage/users' | '/manage/wells' | '/reports/chlorides' @@ -311,6 +322,7 @@ export interface FileRouteTypes { | '/manage/backups' | '/manage/meters' | '/manage/parts' + | '/manage/serviceaccounts' | '/manage/users' | '/manage/wells' | '/reports/chlorides' @@ -339,6 +351,7 @@ export interface RootRouteChildren { ManageBackupsRoute: typeof ManageBackupsRoute ManageMetersRoute: typeof ManageMetersRoute ManagePartsRoute: typeof ManagePartsRouteWithChildren + ManageServiceaccountsRoute: typeof ManageServiceaccountsRoute ManageUsersRoute: typeof ManageUsersRoute ManageWellsRoute: typeof ManageWellsRoute ReportsChloridesRoute: typeof ReportsChloridesRoute @@ -479,6 +492,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ManageUsersRouteImport parentRoute: typeof rootRouteImport } + '/manage/serviceaccounts': { + id: '/manage/serviceaccounts' + path: '/manage/serviceaccounts' + fullPath: '/manage/serviceaccounts' + preLoaderRoute: typeof ManageServiceaccountsRouteImport + parentRoute: typeof rootRouteImport + } '/manage/parts': { id: '/manage/parts' path: '/manage/parts' @@ -571,6 +591,7 @@ const rootRouteChildren: RootRouteChildren = { ManageBackupsRoute: ManageBackupsRoute, ManageMetersRoute: ManageMetersRoute, ManagePartsRoute: ManagePartsRouteWithChildren, + ManageServiceaccountsRoute: ManageServiceaccountsRoute, ManageUsersRoute: ManageUsersRoute, ManageWellsRoute: ManageWellsRoute, ReportsChloridesRoute: ReportsChloridesRoute, diff --git a/frontend/src/routes/manage/serviceaccounts.tsx b/frontend/src/routes/manage/serviceaccounts.tsx new file mode 100644 index 00000000..bbb0d1a1 --- /dev/null +++ b/frontend/src/routes/manage/serviceaccounts.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { ServiceAccountManagementView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + booleanParam, + optionalPositiveInt, + pageParam, + routeSearchHydrator, + triStateParam, +} from "@/utils"; + +const searchSchema = z.object({ + service_account_id: optionalPositiveInt.catch(undefined).default(undefined), + service_account_add: booleanParam(true), + service_account_q: z.string().catch("").default(""), + service_account_active: triStateParam("true"), + sa_page: pageParam(0, 0), + sa_pageSize: pageParam(10, 10), +}); + +export const Route = createFileRoute("/manage/serviceaccounts")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/service/users.ts b/frontend/src/service/users.ts index b43b56b3..be2f78c8 100644 --- a/frontend/src/service/users.ts +++ b/frontend/src/service/users.ts @@ -8,6 +8,8 @@ import { import { useApiClient } from "@/hooks"; import { AuthTokenResponse, + ServiceAccount, + ServiceAccountForm, UpdatedUserPassword, User, UserRole, @@ -52,6 +54,19 @@ export function useGetUserList() { return useQuery([route], () => apiClient.get(route)); } +export function useGetServiceAccounts( + options?: UseQueryOptions, +) { + const apiClient = useApiClient(); + const route = "service-accounts"; + + return useQuery( + [route], + () => apiClient.get(route), + options, + ); +} + export function useGetUser(id: number, options = {}) { const apiClient = useApiClient(); const route = "users"; @@ -82,6 +97,133 @@ export function useImpersonateUser() { }); } +export function useCreateServiceAccount(onSuccess: Function) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + const route = "service-accounts"; + + return useMutation({ + mutationFn: async (serviceAccount: ServiceAccountForm) => { + const response = await apiClient.post(route, serviceAccount); + + if (!response.ok) { + const message = await getErrorMessage(response); + enqueueSnackbar(message, { variant: "error" }); + throw Error(message); + } + + const responseJson = (await response.json()) as ServiceAccount; + queryClient.setQueryData( + [route], + (old: ServiceAccount[] | undefined) => [...(old ?? []), responseJson], + ); + onSuccess(responseJson); + return responseJson; + }, + retry: 0, + }); +} + +export function useUpdateServiceAccount(onSuccess: Function) { + const { enqueueSnackbar } = useSnackbar(); + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const route = "service-accounts"; + + return useMutation({ + mutationFn: async (serviceAccount: ServiceAccountForm) => { + const response = await apiClient.patch( + `${route}/${serviceAccount.id}`, + serviceAccount, + ); + + if (!response.ok) { + const message = await getErrorMessage(response); + enqueueSnackbar(message, { variant: "error" }); + throw Error(message); + } + + const responseJson = (await response.json()) as ServiceAccount; + queryClient.setQueryData( + [route], + (old: ServiceAccount[] | undefined) => + (old ?? []).map((item) => + item.id === responseJson.id ? responseJson : item, + ), + ); + onSuccess(responseJson); + return responseJson; + }, + retry: 0, + }); +} + +export function useCreateServiceAccountKey(onSuccess: Function) { + const { enqueueSnackbar } = useSnackbar(); + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const route = "service-accounts"; + + return useMutation({ + mutationFn: async (serviceAccountId: number) => { + const response = await apiClient.post( + `${route}/${serviceAccountId}/keys`, + undefined, + ); + + if (!response.ok) { + const message = await getErrorMessage(response); + enqueueSnackbar(message, { variant: "error" }); + throw Error(message); + } + + const responseJson = (await response.json()) as ServiceAccount; + queryClient.setQueryData( + [route], + (old: ServiceAccount[] | undefined) => + (old ?? []).map((item) => + item.id === responseJson.id ? responseJson : item, + ), + ); + onSuccess(responseJson); + return responseJson; + }, + retry: 0, + }); +} + +export function useRevokeServiceAccountKey(onSuccess: Function) { + const { enqueueSnackbar } = useSnackbar(); + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const route = "service-accounts"; + + return useMutation({ + mutationFn: async ({ + serviceAccountId, + keyIdentifier, + }: { + serviceAccountId: number; + keyIdentifier: string; + }) => { + const response = await apiClient.delete( + `${route}/${serviceAccountId}/keys/${keyIdentifier}`, + ); + + if (!response.ok) { + const message = await getErrorMessage(response); + enqueueSnackbar(message, { variant: "error" }); + throw Error(message); + } + + queryClient.invalidateQueries({ queryKey: [route] }); + onSuccess(); + }, + retry: 0, + }); +} + export function useCreateUser(onSuccess: Function) { const { enqueueSnackbar } = useSnackbar(); const queryClient = useQueryClient(); diff --git a/frontend/src/views/UserManagement/ServiceAccountDetailsCard.tsx b/frontend/src/views/UserManagement/ServiceAccountDetailsCard.tsx new file mode 100644 index 00000000..c4737f18 --- /dev/null +++ b/frontend/src/views/UserManagement/ServiceAccountDetailsCard.tsx @@ -0,0 +1,398 @@ +import { useEffect, useMemo, useState } from "react"; +import { Resolver, useForm } from "react-hook-form"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Grid, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { + Add, + ContentCopy, + DeleteOutline, + Edit, + Key, + Save, + SaveAs, +} from "@mui/icons-material"; +import * as Yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { enqueueSnackbar } from "notistack"; + +import { + useCreateServiceAccount, + useCreateServiceAccountKey, + useGetRoles, + useGetServiceAccounts, + useRevokeServiceAccountKey, + useUpdateServiceAccount, +} from "@/service"; +import { + ControlledSelect, + ControlledSelectNonObject, + ControlledTextbox, + CustomCardHeader, + IsTrueChip, +} from "@/components"; +import { + ServiceAccount, + ServiceAccountApiKey, + ServiceAccountForm, + UserRole, +} from "@/interfaces"; +import { toGMT6String } from "@/utils"; + +const ServiceAccountResolverSchema = Yup.object().shape({ + full_name: Yup.string().required("Please enter a name."), + username: Yup.string().required("Please enter an identifier."), + disabled: Yup.boolean().required("Please indicate if account is active."), + user_role: Yup.object().required("Please indicate the account role."), +}); + +const serviceAccountResolver = yupResolver( + ServiceAccountResolverSchema, +) as unknown as Resolver; + +const formatDateTime = (value?: string | null) => + value ? toGMT6String(new Date(value)) : "-"; + +const formatSubmission = ( + serviceAccount: ServiceAccountForm, + serviceAccountAddMode: boolean, +) => { + const formattedServiceAccount: ServiceAccountForm = { + id: serviceAccount.id, + full_name: serviceAccount.full_name, + display_name: serviceAccount.display_name, + disabled: serviceAccount.disabled, + user_role_id: serviceAccount.user_role?.id, + }; + + if (serviceAccountAddMode) { + formattedServiceAccount.username = serviceAccount.username; + } + + return formattedServiceAccount; +}; + +const ApiKeyRows = ({ + serviceAccountId, + apiKeys, + revokeKey, +}: { + serviceAccountId?: number; + apiKeys: ServiceAccountApiKey[]; + revokeKey: ReturnType; +}) => { + if (!serviceAccountId || apiKeys.length === 0) { + return ( + + No keys have been created. + + ); + } + + return ( + + + + Prefix + Created + Last Used + Status + Actions + + + + {apiKeys.map((apiKey) => { + const isRevoked = !!apiKey.revoked_at; + return ( + + {apiKey.key_prefix}... + {formatDateTime(apiKey.created_at)} + {formatDateTime(apiKey.last_used_at)} + + + + + + + { + if (!window.confirm("Revoke this service account key?")) { + return; + } + revokeKey.mutate({ + serviceAccountId, + keyIdentifier: apiKey.key_identifier, + }); + }} + > + + + + + + + ); + })} + +
+ ); +}; + +export const ServiceAccountDetailsCard = ({ + serviceAccountId, + serviceAccountAddMode, +}: { + serviceAccountId?: number; + serviceAccountAddMode: boolean; +}) => { + const [latestApiKey, setLatestApiKey] = useState(""); + const serviceAccounts = useGetServiceAccounts(); + const selectedServiceAccount = useMemo( + () => + (serviceAccounts.data ?? []).find( + (serviceAccount) => serviceAccount.id === serviceAccountId, + ), + [serviceAccountId, serviceAccounts.data], + ); + const rolesList = useGetRoles(); + const { + handleSubmit, + control, + reset, + setValue, + formState: { errors }, + } = useForm({ resolver: serviceAccountResolver }); + + useEffect(() => { + setLatestApiKey(""); + if (!serviceAccountAddMode && selectedServiceAccount) { + reset(); + Object.entries(selectedServiceAccount).forEach(([k, v]) => + setValue(k as keyof ServiceAccountForm, v as never), + ); + } + if (serviceAccountAddMode) { + reset({ disabled: false }); + } + }, [serviceAccountAddMode, selectedServiceAccount, reset, setValue]); + + const onSuccessfulCreate = (serviceAccount: ServiceAccount) => { + enqueueSnackbar("Successfully created service account.", { + variant: "success", + }); + setLatestApiKey(serviceAccount.api_key ?? ""); + }; + + const onSuccessfulUpdate = () => + enqueueSnackbar("Successfully updated service account.", { + variant: "success", + }); + + const onSuccessfulKeyCreate = (serviceAccount: ServiceAccount) => { + enqueueSnackbar("Created a new service account key.", { + variant: "success", + }); + setLatestApiKey(serviceAccount.api_key ?? ""); + }; + + const createServiceAccount = useCreateServiceAccount(onSuccessfulCreate); + const updateServiceAccount = useUpdateServiceAccount(onSuccessfulUpdate); + const createKey = useCreateServiceAccountKey(onSuccessfulKeyCreate); + const revokeKey = useRevokeServiceAccountKey(() => + enqueueSnackbar("Revoked service account key.", { variant: "success" }), + ); + + const onSaveChanges = (serviceAccount: ServiceAccountForm) => + updateServiceAccount.mutate( + formatSubmission(serviceAccount, serviceAccountAddMode), + ); + + const onCreateServiceAccount = (serviceAccount: ServiceAccountForm) => + createServiceAccount.mutate( + formatSubmission(serviceAccount, serviceAccountAddMode), + ); + + const onCopyLatestKey = async () => { + if (!latestApiKey) { + enqueueSnackbar("No API key to copy.", { variant: "info" }); + return; + } + + try { + await navigator.clipboard.writeText(latestApiKey); + enqueueSnackbar("API key copied.", { variant: "success" }); + } catch { + enqueueSnackbar("Unable to copy API key.", { variant: "error" }); + } + }; + + const sortedApiKeys = [...(selectedServiceAccount?.api_keys ?? [])].sort( + (a, b) => b.created_at.localeCompare(a.created_at), + ); + + return ( + + + + + + + + + + + + + + + (label ? "False" : "True")} + error={errors?.disabled?.message} + /> + + + role.name} + control={control} + error={errors?.user_role?.message} + /> + + + + + {serviceAccountAddMode ? ( + + ) : ( + <> + + + + )} + + + {latestApiKey ? ( + } + onClick={onCopyLatestKey} + > + Copy + + } + > + + + API key created. Store it now. + + + + + ) : null} + + {!serviceAccountAddMode ? ( + + + API Keys + + + + ) : null} + + + ); +}; diff --git a/frontend/src/views/UserManagement/ServiceAccountManagementView.tsx b/frontend/src/views/UserManagement/ServiceAccountManagementView.tsx new file mode 100644 index 00000000..65bee0cb --- /dev/null +++ b/frontend/src/views/UserManagement/ServiceAccountManagementView.tsx @@ -0,0 +1,50 @@ +import { Grid } from "@mui/material"; +import { useNavigate } from "@tanstack/react-router"; +import { BackgroundBox } from "@/components"; +import { Route } from "@/routes/manage/serviceaccounts"; +import { ServiceAccountDetailsCard } from "@/views/UserManagement/ServiceAccountDetailsCard"; +import { ServiceAccountsTable } from "@/views/UserManagement/ServiceAccountsTable"; + +export const ServiceAccountManagementView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/serviceaccounts", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + return ( + + + + + setSearch((prev) => ({ + ...prev, + service_account_id: id, + service_account_add: false, + })) + } + onCreateServiceAccount={() => + setSearch((prev) => ({ + ...prev, + service_account_id: undefined, + service_account_add: true, + })) + } + /> + + + + + + + ); +}; diff --git a/frontend/src/views/UserManagement/ServiceAccountsTable.tsx b/frontend/src/views/UserManagement/ServiceAccountsTable.tsx new file mode 100644 index 00000000..9a22b5c4 --- /dev/null +++ b/frontend/src/views/UserManagement/ServiceAccountsTable.tsx @@ -0,0 +1,198 @@ +import { useMemo } from "react"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { + Box, + Button, + Card, + CardContent, + Grid, + InputAdornment, + TextField, +} from "@mui/material"; +import { Add, ManageAccounts, Search } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/serviceaccounts"; +import { useGetServiceAccounts } from "@/service"; +import { + CustomCardHeader, + GridFooterWithButton, + IsTrueChip, + ManageBreadcrumbTitle, + RoleChip, + TristateToggle, +} from "@/components"; + +export const ServiceAccountsTable = ({ + onSelectServiceAccount, + onCreateServiceAccount, +}: { + onSelectServiceAccount: (id: number) => void; + onCreateServiceAccount: () => void; +}) => { + const serviceAccounts = useGetServiceAccounts(); + const navigate = useNavigate(); + const search = Route.useSearch(); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/serviceaccounts", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + const filteredRows = useMemo(() => { + const q = (search.service_account_q ?? "").toLowerCase(); + let rows = (serviceAccounts.data ?? []).filter( + (row) => + row.full_name.toLowerCase().includes(q) || + row.display_name?.toLowerCase().includes(q) || + row.username?.toLowerCase().includes(q), + ); + + if (search.service_account_active !== "all") { + const wantActive = search.service_account_active === "true"; + rows = rows.filter((row) => !row.disabled === wantActive); + } + + return rows; + }, [ + serviceAccounts.data, + search.service_account_active, + search.service_account_q, + ]); + + const cols: GridColDef[] = [ + { field: "full_name", headerName: "Name", width: 220 }, + { field: "username", headerName: "Identifier", width: 210 }, + { + field: "user_role", + headerName: "Role", + width: 230, + valueGetter: (_, row) => row.user_role?.name, + renderCell: (params: any) => , + }, + { + field: "disabled", + headerName: "Active", + width: 90, + renderCell: (params: any) => , + }, + { + field: "api_keys", + headerName: "Active Keys", + width: 120, + valueGetter: (_, row) => + row.api_keys?.filter((key: any) => !key.revoked_at).length ?? 0, + }, + { field: "display_name", headerName: "Display Name", width: 180 }, + ]; + + return ( + + } + icon={ManageAccounts} + /> + + + + + setSearch((prev) => ({ + ...prev, + service_account_q: e.target.value, + sa_page: 0, + })) + } + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + setSearch((prev) => ({ + ...prev, + service_account_active: next, + sa_page: 0, + })) + } + /> + + + + + setSearch((prev) => ({ + ...prev, + sa_pageSize: model.pageSize, + sa_page: + model.pageSize !== prev.sa_pageSize ? 0 : model.page, + })) + } + pageSizeOptions={[10, 25, 50]} + rowSelectionModel={ + search.service_account_id ? [search.service_account_id] : [] + } + loading={serviceAccounts.isLoading} + columns={cols} + disableColumnMenu + disableColumnFilter + onRowClick={(r) => { + if (search.service_account_id === r.row.id) { + onCreateServiceAccount(); + return; + } + + onSelectServiceAccount(r.row.id); + }} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: ( + + ), + }, + }} + /> + + + + ); +}; diff --git a/frontend/src/views/UserManagement/index.ts b/frontend/src/views/UserManagement/index.ts index 8350cab9..f63dc22f 100644 --- a/frontend/src/views/UserManagement/index.ts +++ b/frontend/src/views/UserManagement/index.ts @@ -1 +1,2 @@ export * from './UserManagementView' +export * from './ServiceAccountManagementView' From 23460b26b6ab0e8eecbcc6da44ecdfbc18485fc7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 16 Jul 2026 13:27:27 -0500 Subject: [PATCH 11/21] feat(meters_report): add bar chart to installed & sold meters report PDFs --- api/services/meters.py | 42 ++++++++++++++++++++++ api/templates/installed_meters_report.html | 8 +++++ api/templates/sold_meters_report.html | 8 +++++ 3 files changed, 58 insertions(+) diff --git a/api/services/meters.py b/api/services/meters.py index ebae97ce..a8023529 100644 --- a/api/services/meters.py +++ b/api/services/meters.py @@ -1,9 +1,14 @@ +from base64 import b64encode from enum import Enum from datetime import date, datetime from io import BytesIO from pathlib import Path +import matplotlib +matplotlib.use("Agg") + from jinja2 import Environment, FileSystemLoader, select_autoescape +from matplotlib.pyplot import close, figure from sqlalchemy import select from sqlalchemy.orm import Session, joinedload from weasyprint import HTML @@ -131,6 +136,33 @@ def _meter_type_label(meter_type: MeterTypeLU) -> str: ) +def _make_meter_type_bar_chart(type_totals: list[dict], series_label: str) -> str: + if not type_totals: + return "" + + labels = [row["meter_type"] for row in type_totals] + quantities = [row["quantity"] for row in type_totals] + width = max(8, min(14, len(labels) * 1.2)) + + fig = figure(figsize=(width, 5)) + ax = fig.add_subplot(111) + bars = ax.bar(labels, quantities, label=series_label, color="#1976d2") + + ax.set_title("Meter Type Totals") + ax.set_xlabel("Meter Type") + ax.set_ylabel("Quantity") + ax.set_ylim(0, max(quantities) + 1) + ax.legend() + ax.bar_label(bars, padding=3) + ax.tick_params(axis="x", labelrotation=35) + + fig.tight_layout() + buf = BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight") + close(fig) + return b64encode(buf.getvalue()).decode("utf-8") + + def get_sold_meters_report( db: Session, from_date: date, @@ -316,11 +348,16 @@ def build_sold_meters_pdf( max_size: int | None = None, ): report = get_sold_meters_report(db, from_date, to_date, min_size, max_size) + meter_type_chart = _make_meter_type_bar_chart( + report["type_totals"], + "Meters Sold", + ) html_content = templates.get_template("sold_meters_report.html").render( rows=report["rows"], summary=report["summary"], type_totals=report["type_totals"], + meter_type_chart=meter_type_chart, from_date=from_date, to_date=to_date, min_size=min_size, @@ -340,11 +377,16 @@ def build_installed_meters_pdf( max_size: int | None = None, ): report = get_installed_meters_report(db, from_date, to_date, min_size, max_size) + meter_type_chart = _make_meter_type_bar_chart( + report["type_totals"], + "Meters Installed", + ) html_content = templates.get_template("installed_meters_report.html").render( rows=report["rows"], summary=report["summary"], type_totals=report["type_totals"], + meter_type_chart=meter_type_chart, from_date=from_date, to_date=to_date, min_size=min_size, diff --git a/api/templates/installed_meters_report.html b/api/templates/installed_meters_report.html index 73656784..8d6ecca3 100644 --- a/api/templates/installed_meters_report.html +++ b/api/templates/installed_meters_report.html @@ -15,6 +15,8 @@ th { background: #f3f4f6; } td.number, th.number { text-align: right; } tr { page-break-inside: avoid; } + .chart { margin: 12px 0 18px; text-align: center; } + .chart img { max-width: 100%; } @@ -35,6 +37,12 @@

Installed Meters Report

Meter Type Totals

+ {% if meter_type_chart %} +
+ +
+ {% endif %} + diff --git a/api/templates/sold_meters_report.html b/api/templates/sold_meters_report.html index 687321f5..8fc7827f 100644 --- a/api/templates/sold_meters_report.html +++ b/api/templates/sold_meters_report.html @@ -15,6 +15,8 @@ th { background: #f3f4f6; } td.number, th.number { text-align: right; } tr { page-break-inside: avoid; } + .chart { margin: 12px 0 18px; text-align: center; } + .chart img { max-width: 100%; } @@ -35,6 +37,12 @@

Sold Meters Report

Meter Type Totals

+ {% if meter_type_chart %} +
+ +
+ {% endif %} +
From 1e7c55d87fcf1e26810ab640e87e5970c49a244d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 16 Jul 2026 14:07:33 -0500 Subject: [PATCH 12/21] refactor(Reports/PartsUsed): update ui to cut off input labels after 3+ items --- .../forms/controlled/ControlledSelect.tsx | 23 ++++--- .../src/views/Reports/PartsUsed/index.tsx | 68 ++++++++++++++++++- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/forms/controlled/ControlledSelect.tsx b/frontend/src/components/forms/controlled/ControlledSelect.tsx index 70bae13a..5b58093d 100644 --- a/frontend/src/components/forms/controlled/ControlledSelect.tsx +++ b/frontend/src/components/forms/controlled/ControlledSelect.tsx @@ -60,18 +60,21 @@ export function ControlledSelect({ onChange={handleChange} defaultValue={isMultiple ? [] : ""} label={childProps.label} - renderValue={(selected: any) => - isMultiple - ? options - .filter((opt: any) => selected.includes(opt.id)) + renderValue={(selected: any) => { + const selectedOptions = isMultiple + ? options.filter((opt: any) => selected.includes(opt.id)) + : [options.find((opt: any) => opt.id === selected) ?? {}]; + + if (childProps.renderValue) { + return childProps.renderValue(selectedOptions); + } + + return isMultiple + ? selectedOptions .map((opt: any) => childProps.getOptionLabel(opt)) .join(", ") - : childProps.getOptionLabel( - options.find( - (opt: any) => opt.id === selected, - ) ?? {}, - ) - } + : childProps.getOptionLabel(selectedOptions[0]); + }} > {options.map((option: any) => ( diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index ae7a6e46..cab50ec9 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -6,6 +6,7 @@ import { Button, Card, CardContent, + Chip, FormControlLabel, Grid, Switch, @@ -31,6 +32,9 @@ import { } from "@/components"; import { Route } from "@/routes/reports/partsused"; +const MAX_VISIBLE_PART_CHIPS = 3; +const MAX_VISIBLE_PART_TYPE_NAMES = 3; + export interface MeterType { id: number; brand: string; @@ -478,7 +482,14 @@ export const PartsUsedReportView = () => { ) : ( { disabled={partsQuery.isFetching} options={partTypeOptions} getOptionLabel={(option: any) => option.type.name} + renderValue={(selectedOptions: any[]) => { + const visibleLabels = selectedOptions + .slice(0, MAX_VISIBLE_PART_TYPE_NAMES) + .map((option) => option.type.name); + + if (selectedOptions.length > MAX_VISIBLE_PART_TYPE_NAMES) { + visibleLabels.push("..."); + } + + return visibleLabels.join(", "); + }} /> )} @@ -550,6 +572,36 @@ export const PartsUsedReportView = () => { onChange={(_, selectedOptions) => field.onChange(selectedOptions.map((p) => p.id)) } + renderTags={(value: Part[], getTagProps) => { + const visibleParts = value.slice( + 0, + MAX_VISIBLE_PART_CHIPS, + ); + const overflowCount = + value.length - visibleParts.length; + + return [ + ...visibleParts.map((option, index) => ( + + )), + ...(overflowCount > 0 + ? [ + , + ] + : []), + ]; + }} filterOptions={(options: Part[], state: any) => options.filter((opt) => `${opt.part_number} ${opt.description}` @@ -558,6 +610,18 @@ export const PartsUsedReportView = () => { ) } loading={partsQuery.isLoading} + sx={{ + "& .MuiAutocomplete-inputRoot": { + flexWrap: "nowrap", + overflow: "hidden", + }, + "& .MuiAutocomplete-tag": { + flexShrink: 1, + }, + "& .MuiAutocomplete-input": { + minWidth: "64px !important", + }, + }} renderInput={(params) => ( { disabled={partsQuery.isFetching} sx={{ whiteSpace: "nowrap", - height: hasSelectedParts ? 50 : 40, + height: 40, }} > {hasSelectedParts ? "Deselect All" : "Select All"} From 7042343fc8a2ea81c31da98e8a37d26f3a1aefc4 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 16 Jul 2026 14:17:02 -0500 Subject: [PATCH 13/21] fix(Reports/PartsUsed): update pdf report template to match the new parts used changes --- api/services/parts.py | 39 +++++- api/templates/parts_used_report.html | 22 +++- .../src/views/Reports/PartsUsed/index.tsx | 121 ++++++++++++++++-- 3 files changed, 171 insertions(+), 11 deletions(-) diff --git a/api/services/parts.py b/api/services/parts.py index 039805c5..be6442fb 100644 --- a/api/services/parts.py +++ b/api/services/parts.py @@ -10,7 +10,7 @@ from weasyprint import HTML from api.models.meter import ActivityTypeLU, MeterActivities, meterRegisters -from api.models.part import Parts, PartsAdded, PartsUsed +from api.models.part import PartTypeLU, Parts, PartsAdded, PartsUsed from api.schemas import parts @@ -196,11 +196,13 @@ def get_parts_used_summary( Parts.part_number, Parts.description, Parts.price, + PartTypeLU.name.label("part_type"), func.coalesce(usage_subq.c.quantity, 0).label("quantity"), ) .outerjoin(usage_subq, Parts.id == usage_subq.c.used_part_id) + .join(PartTypeLU, PartTypeLU.id == Parts.part_type_id) .filter(Parts.id.in_(parts)) - .order_by(Parts.part_number) + .order_by(PartTypeLU.name, Parts.part_number) ) results = [] for row in query.all(): @@ -211,6 +213,7 @@ def get_parts_used_summary( "id": row.id, "part_number": row.part_number, "description": row.description, + "part_type": row.part_type, "price": price, "quantity": quantity, "total": price * quantity, @@ -226,8 +229,11 @@ def build_parts_used_pdf(db: Session, from_date: date, to_date: date, parts: lis running_total += row["total"] row["running_total"] = running_total + summary_rows = build_parts_used_type_summary(results) + html_content = templates.get_template("parts_used_report.html").render( rows=results, + summary_rows=summary_rows, from_date=from_date, to_date=to_date, ) @@ -237,6 +243,35 @@ def build_parts_used_pdf(db: Session, from_date: date, to_date: date, parts: lis return pdf_io +def build_parts_used_type_summary(rows: list[dict]): + summary_by_type = {} + + for row in rows: + part_type = row.get("part_type") or "Other" + if part_type not in summary_by_type: + summary_by_type[part_type] = { + "part_type": part_type, + "quantity": 0, + "total": 0.0, + "running_total": 0.0, + } + + summary_by_type[part_type]["quantity"] += row["quantity"] + summary_by_type[part_type]["total"] += row["total"] + summary_by_type[part_type]["running_total"] += row.get("running_total", 0) + + summary_rows = list(summary_by_type.values()) + summary_rows.append( + { + "part_type": "Grand total", + "quantity": sum(row["quantity"] for row in summary_rows), + "total": sum(row["total"] for row in summary_rows), + "running_total": sum(row["running_total"] for row in summary_rows), + } + ) + return summary_rows + + def get_part(db: Session, part_id: int): used_subq, added_subq, current_count = _part_count_subqueries() row = db.execute( diff --git a/api/templates/parts_used_report.html b/api/templates/parts_used_report.html index dab25a57..6a3cb046 100644 --- a/api/templates/parts_used_report.html +++ b/api/templates/parts_used_report.html @@ -33,6 +33,26 @@

Parts Usage Report

To: {{ to_date }}

+
+ + + + + + + + + + {% for row in summary_rows %} + + + + + + + {% endfor %} + +
TypeNumber of unitsTotal costRunning Total
{{ row.part_type }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}
@@ -59,4 +79,4 @@

Parts Usage Report

- \ No newline at end of file + diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index cab50ec9..c422a685 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -66,6 +66,26 @@ export interface Part { meter_types: MeterType[]; } +interface PartsUsedReportRow { + id: number; + part_number: string; + description: string; + part_type: string | null; + price: number; + quantity: number; + total: number; + running_total: number; +} + +interface PartsUsedTypeSummaryRow { + id: string; + part_type: string; + quantity: number; + total: number; + running_total: number; + isGrandTotal?: boolean; +} + const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), to: yup @@ -299,7 +319,7 @@ export const PartsUsedReportView = () => { }); }, [from, to, partTypes, selectedPartIds, inUse]); - const partsUsedQuery = useQuery({ + const partsUsedQuery = useQuery[]>({ queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], queryFn: async () => { const searchParams = new URLSearchParams({ @@ -329,7 +349,7 @@ export const PartsUsedReportView = () => { let runningTotal = 0; - const rows = partsUsedQuery?.data?.map((part) => { + const rows: PartsUsedReportRow[] = (partsUsedQuery?.data ?? []).map((part) => { runningTotal += part.total; return { ...part, @@ -337,6 +357,75 @@ export const PartsUsedReportView = () => { }; }); + const summaryRows: PartsUsedTypeSummaryRow[] = useMemo(() => { + const summaries = rows.reduce>( + (acc, row) => { + const partType = row.part_type || "Other"; + + if (!acc[partType]) { + acc[partType] = { + id: partType, + part_type: partType, + quantity: 0, + total: 0, + running_total: 0, + }; + } + + acc[partType].quantity += row.quantity; + acc[partType].total += row.total; + acc[partType].running_total += row.running_total; + + return acc; + }, + {}, + ); + + const groupedRows = Object.values(summaries); + + if (!groupedRows.length) return []; + + return [ + ...groupedRows, + { + id: "grand-total", + part_type: "Grand total", + quantity: groupedRows.reduce((total, row) => total + row.quantity, 0), + total: groupedRows.reduce((total, row) => total + row.total, 0), + running_total: groupedRows.reduce( + (total, row) => total + row.running_total, + 0, + ), + isGrandTotal: true, + }, + ]; + }, [rows]); + + const currencyFormatter = (param: number) => + typeof param === "number" ? `$${param.toFixed(2)}` : "$0.00"; + + const summaryColumns: GridColDef[] = [ + { field: "part_type", headerName: "Type", flex: 1 }, + { + field: "quantity", + headerName: "Number of units", + flex: 1, + type: "number", + }, + { + field: "total", + headerName: "Total cost", + flex: 1, + valueFormatter: currencyFormatter, + }, + { + field: "running_total", + headerName: "Running Total", + flex: 1, + valueFormatter: currencyFormatter, + }, + ]; + const columns: GridColDef[] = [ { field: "part_number", headerName: "Part", flex: 1 }, { field: "description", headerName: "Description", flex: 2 }, @@ -344,8 +433,7 @@ export const PartsUsedReportView = () => { field: "price", headerName: "Cost per unit", flex: 1, - valueFormatter: (param: number) => - typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", + valueFormatter: currencyFormatter, }, { field: "quantity", @@ -357,15 +445,13 @@ export const PartsUsedReportView = () => { field: "total", headerName: "Total cost", flex: 1, - valueFormatter: (param: number) => - typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", + valueFormatter: currencyFormatter, }, { field: "running_total", headerName: "Running Total", flex: 1, - valueFormatter: (param: number) => - typeof param === "number" ? `$${param.toFixed(2)}` : "$0.00", + valueFormatter: currencyFormatter, }, ]; @@ -672,6 +758,25 @@ export const PartsUsedReportView = () => { /> + + + params.row.isGrandTotal ? "parts-used-grand-total" : "" + } + sx={{ + mb: 2, + "& .parts-used-grand-total": { + fontWeight: 700, + bgcolor: "action.hover", + }, + }} + /> + Date: Thu, 16 Jul 2026 14:28:09 -0500 Subject: [PATCH 14/21] feat(Reports/PartsUsed): add part type column to bottom table --- .../src/views/Reports/PartsUsed/index.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index c422a685..e99ce170 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -349,13 +349,15 @@ export const PartsUsedReportView = () => { let runningTotal = 0; - const rows: PartsUsedReportRow[] = (partsUsedQuery?.data ?? []).map((part) => { - runningTotal += part.total; - return { - ...part, - running_total: runningTotal, - }; - }); + const rows: PartsUsedReportRow[] = (partsUsedQuery?.data ?? []).map( + (part) => { + runningTotal += part.total; + return { + ...part, + running_total: runningTotal, + }; + }, + ); const summaryRows: PartsUsedTypeSummaryRow[] = useMemo(() => { const summaries = rows.reduce>( @@ -408,6 +410,8 @@ export const PartsUsedReportView = () => { { field: "part_type", headerName: "Type", flex: 1 }, { field: "quantity", + align: "left", + headerAlign: "left", headerName: "Number of units", flex: 1, type: "number", @@ -428,15 +432,20 @@ export const PartsUsedReportView = () => { const columns: GridColDef[] = [ { field: "part_number", headerName: "Part", flex: 1 }, + { field: "part_type", headerName: "Part Type", flex: 1 }, { field: "description", headerName: "Description", flex: 2 }, { field: "price", + align: "left", + headerAlign: "left", headerName: "Cost per unit", flex: 1, valueFormatter: currencyFormatter, }, { field: "quantity", + align: "left", + headerAlign: "left", headerName: "Number of units", flex: 1, type: "number", From fb969b56e004133aeb3dd61cfc6a4f2f19b2f1c5 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 16 Jul 2026 14:31:26 -0500 Subject: [PATCH 15/21] fix(Reports/PartsUsed): rm running total from the part type table --- api/templates/parts_used_report.html | 2 -- frontend/src/views/Reports/PartsUsed/index.tsx | 6 ------ 2 files changed, 8 deletions(-) diff --git a/api/templates/parts_used_report.html b/api/templates/parts_used_report.html index 6a3cb046..2b9d653f 100644 --- a/api/templates/parts_used_report.html +++ b/api/templates/parts_used_report.html @@ -39,7 +39,6 @@

Parts Usage Report

Type Number of units Total cost - Running Total @@ -48,7 +47,6 @@

Parts Usage Report

{{ row.part_type }} {{ row.quantity }} ${{ "%.2f"|format(row.total) }} - ${{ "%.2f"|format(row.running_total) }} {% endfor %} diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index e99ce170..c3205614 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -422,12 +422,6 @@ export const PartsUsedReportView = () => { flex: 1, valueFormatter: currencyFormatter, }, - { - field: "running_total", - headerName: "Running Total", - flex: 1, - valueFormatter: currencyFormatter, - }, ]; const columns: GridColDef[] = [ From bd91280119c34066aaa6bde93d3dd10486f31b9f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 3 Aug 2026 18:38:48 -0500 Subject: [PATCH 16/21] chore(docker-compose): update the URL and APP ENVs --- docker-compose.development.yml | 12 ++++++------ docker-compose.production.yml | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docker-compose.development.yml b/docker-compose.development.yml index b24d431f..b7ceb5e2 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -18,7 +18,7 @@ services: - traefik labels: - "traefik.enable=true" - - "traefik.http.routers.frontend.rule=Host(`pvacd-dev.newmexicowaterdata.org`)" + - "traefik.http.routers.frontend.rule=Host(`metermanager-dev.pvacd.com`)" - "traefik.http.routers.frontend.entrypoints=websecure,web" - "traefik.http.routers.frontend.tls=true" - "traefik.http.routers.frontend.tls.certresolver=myresolver" @@ -34,9 +34,9 @@ services: working_dir: /app environment: - APP_ENV=pre-production - - API_BASE_URL=https://pvacd-dev.newmexicowaterdata.org/api/v1 - - GCP_BUCKET_NAME=pvacd - - GCP_WOODPECKER_BUCKET_NAME=pvacd-woodpecker + - API_BASE_URL=https://metermanager-dev.pvacd.com/api/v1 + - GCP_BUCKET_NAME=meter-manager-bucket + - GCP_WOODPECKER_BUCKET_NAME=meter-manager-woodpecker-bucket - GCP_BACKUP_PREFIX=pre-prod-db-backups - GCP_PHOTO_PREFIX=pre-prod-meter-activities-photos - BACKUP_RETENTION_DAYS=14 @@ -57,14 +57,14 @@ services: - ./api/.env_devserver labels: - "traefik.enable=true" - - "traefik.http.routers.api.rule=Host(`pvacd-dev.newmexicowaterdata.org`) && PathPrefix(`/api/v1`)" + - "traefik.http.routers.api.rule=Host(`metermanager-dev.pvacd.com`) && PathPrefix(`/api/v1`)" - "traefik.http.routers.api.entrypoints=websecure" - "traefik.http.routers.api.tls=true" - "traefik.http.routers.api.tls.certresolver=myresolver" - "traefik.http.routers.api.middlewares=api-sp" - "traefik.http.middlewares.api-sp.stripprefix.prefixes=/api/v1" - "traefik.http.middlewares.api-sp.stripprefix.forceSlash=false" - - "traefik.http.routers.api-insecure.rule=Host(`pvacd-dev.newmexicowaterdata.org`) && PathPrefix(`/api/v1`)" + - "traefik.http.routers.api-insecure.rule=Host(`metermanager-dev.pvacd.com`) && PathPrefix(`/api/v1`)" - "traefik.http.routers.api-insecure.entrypoints=web" # - "traefik.http.routers.api-insecure.middlewares=force-secure" diff --git a/docker-compose.production.yml b/docker-compose.production.yml index a68e115e..e2fe072c 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -18,7 +18,7 @@ services: - traefik labels: - "traefik.enable=true" - - "traefik.http.routers.frontend.rule=Host(`pvacd.newmexicowaterdata.org`)" + - "traefik.http.routers.frontend.rule=Host(`metermanager.pvacd.com`)" - "traefik.http.routers.frontend.entrypoints=websecure,web" - "traefik.http.routers.frontend.tls=true" - "traefik.http.routers.frontend.tls.certresolver=myresolver" @@ -34,9 +34,9 @@ services: working_dir: /app environment: - APP_ENV=production - - API_BASE_URL=https://pvacd.newmexicowaterdata.org/api/v1 - - GCP_BUCKET_NAME=pvacd - - GCP_WOODPECKER_BUCKET_NAME=pvacd-woodpecker + - API_BASE_URL=https://metermanager.pvacd.com/api/v1 + - GCP_BUCKET_NAME=meter-manager-bucket + - GCP_WOODPECKER_BUCKET_NAME=meter-manager-woodpecker-bucket - GCP_BACKUP_PREFIX=prod-db-backups - GCP_PHOTO_PREFIX=prod-meter-activities-photos - BACKUP_RETENTION_DAYS=90 @@ -57,14 +57,14 @@ services: - ./api/.env_production labels: - "traefik.enable=true" - - "traefik.http.routers.api.rule=Host(`pvacd.newmexicowaterdata.org`) && PathPrefix(`/api/v1`)" + - "traefik.http.routers.api.rule=Host(`metermanager.pvacd.com`) && PathPrefix(`/api/v1`)" - "traefik.http.routers.api.entrypoints=websecure" - "traefik.http.routers.api.tls=true" - "traefik.http.routers.api.tls.certresolver=myresolver" - "traefik.http.routers.api.middlewares=api-sp" - "traefik.http.middlewares.api-sp.stripprefix.prefixes=/api/v1" - "traefik.http.middlewares.api-sp.stripprefix.forceSlash=false" - - "traefik.http.routers.api-insecure.rule=Host(`pvacd.newmexicowaterdata.org`) && PathPrefix(`/api/v1`)" + - "traefik.http.routers.api-insecure.rule=Host(`metermanager.pvacd.com`) && PathPrefix(`/api/v1`)" - "traefik.http.routers.api-insecure.entrypoints=web" # - "traefik.http.routers.api-insecure.middlewares=force-secure" From 09aa5ac104edb2d8b7a4999f4fef7ddd0286b2bb Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 3 Aug 2026 19:04:39 -0500 Subject: [PATCH 17/21] chore(frontend/.env): update .env.*.example files --- frontend/.env.devserver.example | 2 +- frontend/.env.production.example | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/.env.devserver.example b/frontend/.env.devserver.example index 6f161e67..ad263829 100644 --- a/frontend/.env.devserver.example +++ b/frontend/.env.devserver.example @@ -1,2 +1,2 @@ -VITE_API_URL=https://pvacd-dev.newmexicowaterdata.org/api/v1 +VITE_API_URL=https://metermanager-dev.pvacd.com/api/v1 VITE_APP_ENV=pre-production diff --git a/frontend/.env.production.example b/frontend/.env.production.example index b77f5f29..41e3aae5 100644 --- a/frontend/.env.production.example +++ b/frontend/.env.production.example @@ -1,2 +1,2 @@ -VITE_API_URL=https://pvacd.newmexicowaterdata.org/api/v1 +VITE_API_URL=https://metermanager.pvacd.com/api/v1 VITE_APP_ENV=production From b61006bb7b84afff71f78e53f07620c835f7da55 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 3 Aug 2026 19:20:58 -0500 Subject: [PATCH 18/21] fix(docker-compose): add needed frontend arg --- docker-compose.development.yml | 1 + docker-compose.production.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/docker-compose.development.yml b/docker-compose.development.yml index b7ceb5e2..7cefa178 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -9,6 +9,7 @@ services: dockerfile: ./Dockerfile args: VITE_APP_ENV: pre-production + VITE_API_URL: https://metermanager-dev.pvacd.com/api/v1 env_file: - ./frontend/.env.devserver ports: diff --git a/docker-compose.production.yml b/docker-compose.production.yml index e2fe072c..81d438b5 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -9,6 +9,7 @@ services: dockerfile: ./Dockerfile args: VITE_APP_ENV: production + VITE_API_URL: https://metermanager.pvacd.com/api/v1 env_file: - ./frontend/.env.production ports: From b024de3094271c34c7bc0bfa88174237d4feb51f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 3 Aug 2026 19:37:15 -0500 Subject: [PATCH 19/21] fix(Dockerfile): update dockerfile to include ARG & ENV from docker compose --- frontend/Dockerfile | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 836c4769..c3bbb371 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,18 +1,32 @@ # stage1 - build react app first FROM node:20-slim as build + WORKDIR /app ENV PATH /app/node_modules/.bin:$PATH + COPY ./package.json /app/ COPY ./package-lock.json /app/ + RUN npm ci COPY . /app -RUN npm run build + +ARG VITE_API_URL +ARG VITE_APP_ENV + +ENV VITE_API_URL=${VITE_API_URL} +ENV VITE_APP_ENV=${VITE_APP_ENV} + +RUN echo "Building with VITE_API_URL=${VITE_API_URL}" && \ + echo "Building with VITE_APP_ENV=${VITE_APP_ENV}" && \ + npm run build # stage 2 - build the final image and copy the react build files FROM nginx:1.23.2-alpine + COPY --from=build /app/dist /usr/share/nginx/html RUN rm /etc/nginx/conf.d/default.conf COPY nginx/nginx.conf /etc/nginx/conf.d + #EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] From ea878f221e1c1c9ace6e38e6e64c43097e7f3541 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 3 Aug 2026 19:52:27 -0500 Subject: [PATCH 20/21] fix(docker-compose): update compose file to use the actually api volumne location --- docker-compose.development.yml | 2 +- docker-compose.production.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 7cefa178..6564fd35 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -51,7 +51,7 @@ services: ports: - "8000:8000" volumes: - - ./api:/api + - ./api:/app/api depends_on: - traefik env_file: diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 81d438b5..0ad883ab 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -51,7 +51,7 @@ services: ports: - "8000:8000" volumes: - - ./api:/api + - ./api:/app/api depends_on: - traefik env_file: From 33f50d2986b1033678621e768c818de95fb0d562 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 3 Aug 2026 19:54:14 -0500 Subject: [PATCH 21/21] chore(package): run audix fix --- frontend/package-lock.json | 44 ++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e253da78..5016f756 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4036,16 +4036,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -4764,9 +4764,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6285,9 +6285,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -6297,7 +6297,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -7916,9 +7917,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", @@ -8426,9 +8427,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -8443,8 +8444,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9314,9 +9316,9 @@ } }, "node_modules/seroval": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.1.tgz", - "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.0.tgz", + "integrity": "sha512-TBwwKfscTEgnBEWmYKKeCcmCGmrJi0LV6qNUY//WBA3MDesh/zfn+KOMq/ckpxM4gZ0ouAE706A1eenekM2sug==", "license": "MIT", "engines": { "node": ">=10"