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: | 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/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/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/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/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", diff --git a/api/main.py b/api/main.py index facd1a99..a2668964 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( @@ -162,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) @@ -177,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 9d68c927..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)) @@ -22,6 +25,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", @@ -38,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 60bf2be7..aacadbd6 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -1,13 +1,14 @@ -from datetime import timedelta +from datetime import datetime, timezone, timedelta +import secrets +import string 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 +from api.models.user import ServiceAccountApiKeys, Users, UserRoles, SecurityScopes from api.schemas import security from api.schemas import admin @@ -15,7 +16,13 @@ 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, + create_service_account_api_key, + ACCESS_TOKEN_EXPIRE_HOURS, + get_password_hash, +) from api.config import settings from pathlib import Path @@ -24,36 +31,161 @@ 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", "") - - -# define response models +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: + 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) + + +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, + 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") + 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() + 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, 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") + 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() + db.refresh(user) return user @@ -64,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) @@ -90,17 +229,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( @@ -123,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) """ @@ -135,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") @@ -161,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, @@ -195,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: @@ -202,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) @@ -423,7 +735,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 +753,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/meters.py b/api/routes/meters.py index 9cd09a1b..918c31d5 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,104 @@ 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.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/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/routes/work_orders.py b/api/routes/work_orders.py index 9ca7ff44..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,18 +43,23 @@ 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"], ) 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( "/work_orders", + dependencies=[Depends(ScopedUser.WorkOrderUpdate)], response_model=meter.WorkOrder, tags=["Work Orders"], ) @@ -73,5 +78,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/schemas/security.py b/api/schemas/security.py index 8252a9cf..2ea38ffa 100644 --- a/api/schemas/security.py +++ b/api/schemas/security.py @@ -1,5 +1,7 @@ -from api.schemas.base import ORMBase -from pydantic import BaseModel +from datetime import datetime + +from api.schemas.base import ORMBase +from pydantic import BaseModel, Field class SecurityScope(ORMBase): @@ -12,43 +14,85 @@ 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 - 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 - 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 ServiceAccount(User): + is_service_account: bool + api_keys: list[ServiceAccountApiKey] = Field(default_factory=list) + + +class ServiceAccountWithKey(ServiceAccount): + api_key: str 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/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/api/services/meters.py b/api/services/meters.py index 61f22ce4..a8023529 100644 --- a/api/services/meters.py +++ b/api/services/meters.py @@ -1,14 +1,38 @@ +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 -from api.models.meter import MeterActivities, MeterObservations +from api.models.meter import ( + ActivityTypeLU, + MeterActivities, + MeterObservations, + 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 +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 +120,279 @@ 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 _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, + 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 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, + 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) + 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, + max_size=max_size, + ) + pdf_io = BytesIO() + 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) + 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, + 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/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/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/api/templates/installed_meters_report.html b/api/templates/installed_meters_report.html new file mode 100644 index 00000000..8d6ecca3 --- /dev/null +++ b/api/templates/installed_meters_report.html @@ -0,0 +1,97 @@ + + + + + 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

+ {% if meter_type_chart %} +
+ +
+ {% endif %} + + + + + + + + + + + + {% 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/api/templates/parts_used_report.html b/api/templates/parts_used_report.html index dab25a57..2b9d653f 100644 --- a/api/templates/parts_used_report.html +++ b/api/templates/parts_used_report.html @@ -33,6 +33,24 @@

Parts Usage Report

To: {{ to_date }}

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

Parts Usage Report

- \ No newline at end of file + diff --git a/api/templates/sold_meters_report.html b/api/templates/sold_meters_report.html new file mode 100644 index 00000000..8fc7827f --- /dev/null +++ b/api/templates/sold_meters_report.html @@ -0,0 +1,95 @@ + + + + + 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

+ {% if meter_type_chart %} +
+ +
+ {% endif %} + + + + + + + + + + + + {% 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/docker-compose.development.yml b/docker-compose.development.yml index b24d431f..6564fd35 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: @@ -18,7 +19,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 +35,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 @@ -50,21 +51,21 @@ services: ports: - "8000:8000" volumes: - - ./api:/api + - ./api:/app/api depends_on: - traefik env_file: - ./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..0ad883ab 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: @@ -18,7 +19,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 +35,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 @@ -50,21 +51,21 @@ services: ports: - "8000:8000" volumes: - - ./api:/api + - ./api:/app/api depends_on: - traefik env_file: - ./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" 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 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/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;"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4da8c933..5016f756 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" @@ -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", @@ -7400,9 +7401,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" @@ -7906,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", @@ -8416,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", @@ -8433,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" }, @@ -9304,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" @@ -10222,13 +10234,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", 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/constants.ts b/frontend/src/constants.ts index 410b1916..4951edf2 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -5,6 +5,7 @@ import { Science, People, Storage, + ManageAccounts, MonitorHeartOutlined, ConstructionOutlined, BuildOutlined, @@ -12,6 +13,8 @@ import { WaterDrop, SpeedOutlined, Engineering, + EngineeringOutlined, + SellOutlined, } from "@mui/icons-material"; import { SvgIconProps } from "@mui/material"; import { ComponentType } from "react"; @@ -86,10 +89,30 @@ 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", + icon: SellOutlined, + role: "Technician", + parent: "reports", + }, // 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/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/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 8e4d6621..667b83e6 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -19,12 +19,15 @@ 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' +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' +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' @@ -83,6 +86,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', @@ -98,6 +106,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', @@ -113,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', @@ -163,12 +181,15 @@ 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 + '/reports/installedmeters': typeof ReportsInstalledmetersRoute '/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 @@ -187,12 +208,15 @@ 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 + '/reports/installedmeters': typeof ReportsInstalledmetersRoute '/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 @@ -213,12 +237,15 @@ 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 + '/reports/installedmeters': typeof ReportsInstalledmetersRoute '/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 @@ -240,12 +267,15 @@ export interface FileRouteTypes { | '/manage/backups' | '/manage/meters' | '/manage/parts' + | '/manage/serviceaccounts' | '/manage/users' | '/manage/wells' | '/reports/chlorides' + | '/reports/installedmeters' | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/reports/soldmeters' | '/manage/' | '/reports/' | '/manage/parts/' @@ -264,12 +294,15 @@ export interface FileRouteTypes { | '/internal/error-preview' | '/manage/backups' | '/manage/meters' + | '/manage/serviceaccounts' | '/manage/users' | '/manage/wells' | '/reports/chlorides' + | '/reports/installedmeters' | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/reports/soldmeters' | '/manage' | '/reports' | '/manage/parts' @@ -289,12 +322,15 @@ export interface FileRouteTypes { | '/manage/backups' | '/manage/meters' | '/manage/parts' + | '/manage/serviceaccounts' | '/manage/users' | '/manage/wells' | '/reports/chlorides' + | '/reports/installedmeters' | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/reports/soldmeters' | '/manage/' | '/reports/' | '/manage/parts/' @@ -315,12 +351,15 @@ export interface RootRouteChildren { ManageBackupsRoute: typeof ManageBackupsRoute ManageMetersRoute: typeof ManageMetersRoute ManagePartsRoute: typeof ManagePartsRouteWithChildren + ManageServiceaccountsRoute: typeof ManageServiceaccountsRoute ManageUsersRoute: typeof ManageUsersRoute ManageWellsRoute: typeof ManageWellsRoute ReportsChloridesRoute: typeof ReportsChloridesRoute + ReportsInstalledmetersRoute: typeof ReportsInstalledmetersRoute ReportsMaintenanceRoute: typeof ReportsMaintenanceRoute ReportsMonitoringwellsRoute: typeof ReportsMonitoringwellsRoute ReportsPartsusedRoute: typeof ReportsPartsusedRoute + ReportsSoldmetersRoute: typeof ReportsSoldmetersRoute ManageIndexRoute: typeof ManageIndexRoute ReportsIndexRoute: typeof ReportsIndexRoute } @@ -397,6 +436,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' @@ -418,6 +464,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' @@ -439,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' @@ -531,12 +591,15 @@ const rootRouteChildren: RootRouteChildren = { ManageBackupsRoute: ManageBackupsRoute, ManageMetersRoute: ManageMetersRoute, ManagePartsRoute: ManagePartsRouteWithChildren, + ManageServiceaccountsRoute: ManageServiceaccountsRoute, ManageUsersRoute: ManageUsersRoute, ManageWellsRoute: ManageWellsRoute, ReportsChloridesRoute: ReportsChloridesRoute, + ReportsInstalledmetersRoute: ReportsInstalledmetersRoute, ReportsMaintenanceRoute: ReportsMaintenanceRoute, ReportsMonitoringwellsRoute: ReportsMonitoringwellsRoute, ReportsPartsusedRoute: ReportsPartsusedRoute, + ReportsSoldmetersRoute: ReportsSoldmetersRoute, ManageIndexRoute: ManageIndexRoute, ReportsIndexRoute: ReportsIndexRoute, } 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/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/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/service/users.ts b/frontend/src/service/users.ts index 6cc9f0fc..be2f78c8 100644 --- a/frontend/src/service/users.ts +++ b/frontend/src/service/users.ts @@ -8,11 +8,27 @@ import { import { useApiClient } from "@/hooks"; import { AuthTokenResponse, + ServiceAccount, + ServiceAccountForm, UpdatedUserPassword, User, 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"; @@ -38,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"; @@ -68,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(); @@ -85,8 +241,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 +411,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(); @@ -266,3 +424,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/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/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/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index ae7a6e46..c3205614 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; @@ -62,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 @@ -295,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({ @@ -325,26 +349,97 @@ export const PartsUsedReportView = () => { let runningTotal = 0; - const rows = 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>( + (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", + align: "left", + headerAlign: "left", + headerName: "Number of units", + flex: 1, + type: "number", + }, + { + field: "total", + headerName: "Total cost", + flex: 1, + valueFormatter: currencyFormatter, + }, + ]; 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: (param: number) => - typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", + valueFormatter: currencyFormatter, }, { field: "quantity", + align: "left", + headerAlign: "left", headerName: "Number of units", flex: 1, type: "number", @@ -353,15 +448,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, }, ]; @@ -478,7 +571,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 +661,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 +699,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"} @@ -608,6 +761,25 @@ export const PartsUsedReportView = () => { /> + + + params.row.isGrandTotal ? "parts-used-grand-total" : "" + } + sx={{ + mb: 2, + "& .parts-used-grand-total": { + fontWeight: 700, + bgcolor: "action.hover", + }, + }} + /> + ().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..1d0dbf8c 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -2,8 +2,10 @@ import { AssessmentOutlined, BuildOutlined, ConstructionOutlined, + EngineeringOutlined, MonitorHeartOutlined, ScienceOutlined, + SellOutlined, } from "@mui/icons-material"; import { Box, Card, CardContent } from "@mui/material"; import { BackgroundBox, CustomCardHeader, NavLink } from "@/components"; @@ -35,6 +37,16 @@ export const ReportsView = () => { label="Parts Used" icon={BuildOutlined} /> + + 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} + )} /> ; + +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/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index b2a85176..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, @@ -37,29 +47,68 @@ import { CustomCardHeader, } from "@/components"; 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."), 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) => { - 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. + + + - - + + + + + @@ -105,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]); @@ -118,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" }); @@ -133,20 +256,26 @@ 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; } + const evaluation = evaluatePasswordLocally(user.password); + if (!evaluation.is_policy_compliant) { + enqueueSnackbar(evaluation.missing_requirements[0], { variant: "error" }); + return; + } createUser.mutate(formatSubmission(user)); }; @@ -158,6 +287,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, @@ -165,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]); @@ -253,6 +432,9 @@ export const UserDetailsCard = ({ handleSubmit={() => onUpdateUserPassword(watch("id"), watch("password")) } + handleGeneratePassword={onGeneratePassword} + handleCopyPassword={onCopyPassword} + isGeneratingPassword={generateUserPassword.isLoading} /> )}
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' 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, 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; 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";