4 Commits
Author SHA1 Message Date
brian 143c3a0323 Merge pull request 'added settings page' (#21) from add-user-settings-page into main
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 18s
Build and Push Docker Image / build-and-push (push) Successful in 25s
Reviewed-on: #21
2025-11-11 01:01:04 +01:00
Brian Bjarke Jensen d066c6d399 added settings page with ability to update password, delete user and CRUD and share abilities for associated children
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 18s
2025-11-11 00:58:55 +01:00
brian 0d27c47f39 Merge pull request 'updated admin page with more functionality, simplified burger menu and added many-to-many parent-child relationship' (#20) from expand-admin-page into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 17s
Reviewed-on: #20
2025-11-10 23:01:33 +01:00
Brian Bjarke Jensen 402b5898bb updated admin page with more functionality, simplified burger menu and added many-to-many parent-child relationship
Build and Push Docker Image / build-and-push (pull_request) Successful in 59s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-10 22:58:52 +01:00
35 changed files with 2305 additions and 581 deletions
+18
View File
@@ -119,6 +119,24 @@ def serve_sleep() -> FileResponse:
return FileResponse(static_path / "sleep.html")
@app.get("/settings.html", include_in_schema=False)
def serve_settings() -> FileResponse:
"""Serve the settings page."""
return FileResponse(static_path / "settings.html")
@app.get("/menu.css", include_in_schema=False)
def serve_menu_css() -> FileResponse:
"""Serve the shared menu CSS."""
return FileResponse(static_path / "menu.css")
@app.get("/menu.js", include_in_schema=False)
def serve_menu_js() -> FileResponse:
"""Serve the shared menu JavaScript."""
return FileResponse(static_path / "menu.js")
@app.get("/api/")
def read_root(token: Annotated[str, Depends(verify_token)]) -> dict:
"""API root endpoint (requires authentication)."""
-1
View File
@@ -19,5 +19,4 @@ class ChildResponse(BaseModel):
name: str
birth_time: datetime
birth_weight: float
user_id: int
created_at: datetime
@@ -0,0 +1,41 @@
"""Child invitation model for sharing child access."""
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Integer, String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase
else:
from baby_monitor.repositories.dependencies.get_database import Base
class ChildInvitation(Base):
"""Invitation code for sharing child access with other users."""
__tablename__ = "child_invitations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String, unique=True, nullable=False)
child_id: Mapped[int] = mapped_column(
Integer, ForeignKey("children.id"), nullable=False
)
created_by_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
is_consumed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
consumed_by_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
consumed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+2 -3
View File
@@ -1,7 +1,7 @@
"""Child database model."""
from typing import TYPE_CHECKING
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey
from sqlalchemy import Column, Integer, String, DateTime, Float
from datetime import datetime
if TYPE_CHECKING:
@@ -21,8 +21,7 @@ class Child(Base):
name = Column(String, nullable=False)
birth_time = Column(DateTime, nullable=False)
birth_weight = Column(Float, nullable=False) # in grams
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self) -> str:
return f"<Child(id={self.id}, name='{self.name}', user_id={self.user_id})>"
return f"<Child(id={self.id}, name='{self.name}')>"
@@ -0,0 +1,29 @@
"""Child-Parent junction table for many-to-many relationship."""
from typing import TYPE_CHECKING
from sqlalchemy import Column, Integer, ForeignKey, DateTime
from datetime import datetime
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase
else:
from baby_monitor.repositories.dependencies.get_database import Base
class ChildParent(Base):
"""Junction table linking children to their parents (users)."""
__tablename__ = "child_parents"
id = Column(Integer, primary_key=True, index=True)
child_id = Column(Integer, ForeignKey("children.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self) -> str:
return (
f"<ChildParent(id={self.id}, "
f"child_id={self.child_id}, user_id={self.user_id})>"
)
+10
View File
@@ -1,13 +1,23 @@
"""Repository implementations package."""
from baby_monitor.repositories.dependencies import (
get_child_repository,
get_credentials_repository,
get_diaper_change_repository,
get_feeding_repository,
get_invitation_repository,
get_sleep_repository,
get_token_repository,
get_user_repository,
)
__all__ = [
"get_child_repository",
"get_credentials_repository",
"get_diaper_change_repository",
"get_feeding_repository",
"get_invitation_repository",
"get_sleep_repository",
"get_token_repository",
"get_user_repository",
]
@@ -1,12 +1,13 @@
"""SQLite implementation of child repository."""
from datetime import datetime
from datetime import datetime, UTC
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces.child_repository_interface import (
ChildRepositoryInterface,
)
from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
class SQLiteChildRepository(ChildRepositoryInterface):
@@ -18,23 +19,29 @@ class SQLiteChildRepository(ChildRepositoryInterface):
def create(
self, name: str, birth_time: datetime, birth_weight: float, user_id: int
) -> dict:
"""Create a new child record."""
"""Create a new child record and link to user."""
child = Child(
name=name,
birth_time=birth_time,
birth_weight=birth_weight,
user_id=user_id,
)
self.db.add(child)
self.db.commit()
self.db.refresh(child)
# Create the child-parent relationship
child_parent = ChildParent(
child_id=child.id,
user_id=user_id,
)
self.db.add(child_parent)
self.db.commit()
return {
"id": child.id,
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
@@ -49,13 +56,18 @@ class SQLiteChildRepository(ChildRepositoryInterface):
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
def get_by_user_id(self, user_id: int) -> list[dict]:
"""Get all children for a specific user."""
children = self.db.query(Child).filter(Child.user_id == user_id).all()
"""Get all children for a specific user via junction table."""
# Join Child and ChildParent tables
children = (
self.db.query(Child)
.join(ChildParent, Child.id == ChildParent.child_id)
.filter(ChildParent.user_id == user_id)
.all()
)
return [
{
@@ -63,12 +75,59 @@ class SQLiteChildRepository(ChildRepositoryInterface):
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
for child in children
]
def get_parent_ids(self, child_id: int) -> list[int]:
"""Get all parent user IDs for a specific child."""
parent_ids = (
self.db.query(ChildParent.user_id)
.filter(ChildParent.child_id == child_id)
.all()
)
return [user_id for (user_id,) in parent_ids]
def remove_parent(self, child_id: int, user_id: int) -> bool:
"""Remove a parent-child relationship."""
link = (
self.db.query(ChildParent)
.filter(
ChildParent.child_id == child_id,
ChildParent.user_id == user_id,
)
.first()
)
if link:
self.db.delete(link)
self.db.commit()
return True
return False
def add_parent(self, child_id: int, user_id: int) -> bool:
"""Add a parent-child relationship."""
# Check if relationship already exists
existing = (
self.db.query(ChildParent)
.filter(
ChildParent.child_id == child_id,
ChildParent.user_id == user_id,
)
.first()
)
if existing:
return False
link = ChildParent(
child_id=child_id,
user_id=user_id,
created_at=datetime.now(UTC),
)
self.db.add(link)
self.db.commit()
return True
def update(
self,
child_id: int,
@@ -96,16 +155,19 @@ class SQLiteChildRepository(ChildRepositoryInterface):
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
def delete(self, child_id: int) -> bool:
"""Delete a child record."""
"""Delete a child record and all parent relationships."""
child = self.db.query(Child).filter(Child.id == child_id).first()
if not child:
return False
# Delete all parent relationships first
self.db.query(ChildParent).filter(ChildParent.child_id == child_id).delete()
# Delete the child
self.db.delete(child)
self.db.commit()
return True
@@ -0,0 +1,7 @@
"""Child invitation repository implementations."""
from baby_monitor.repositories.child_invitation.sqlite_child_invitation import ( # noqa: E501
SQLiteChildInvitationRepository,
)
__all__ = ["SQLiteChildInvitationRepository"]
@@ -0,0 +1,93 @@
"""SQLite implementation of child invitation repository."""
from datetime import datetime, UTC
from sqlalchemy.orm import Session
from baby_monitor.models.child_invitation import ChildInvitation
from baby_monitor.repositories.interfaces.child_invitation import (
ChildInvitationRepositoryInterface,
)
class SQLiteChildInvitationRepository(ChildInvitationRepositoryInterface):
"""SQLite implementation of child invitation repository."""
def __init__(self, db: Session):
"""Initialize the repository with a database session."""
self.db = db
def create_invitation(
self,
code: str,
child_id: int,
created_by_user_id: int,
expires_at: datetime,
) -> int:
"""Create a new child invitation."""
invitation = ChildInvitation(
code=code,
child_id=child_id,
created_by_user_id=created_by_user_id,
created_at=datetime.now(UTC),
expires_at=expires_at,
is_consumed=False,
)
self.db.add(invitation)
self.db.commit()
self.db.refresh(invitation)
return invitation.id
def get_by_code(self, code: str) -> dict | None:
"""Get invitation by code."""
invitation = (
self.db.query(ChildInvitation).filter(ChildInvitation.code == code).first()
)
if not invitation:
return None
return {
"id": invitation.id,
"code": invitation.code,
"child_id": invitation.child_id,
"created_by_user_id": invitation.created_by_user_id,
"created_at": invitation.created_at,
"expires_at": invitation.expires_at,
"is_consumed": invitation.is_consumed,
"consumed_by_user_id": invitation.consumed_by_user_id,
"consumed_at": invitation.consumed_at,
}
def verify_invitation(self, code: str) -> bool:
"""Verify if an invitation code is valid and not expired."""
invitation = (
self.db.query(ChildInvitation).filter(ChildInvitation.code == code).first()
)
if not invitation:
return False
if invitation.is_consumed:
return False
# Compare as naive UTC datetimes (SQLite stores without timezone)
now_utc = datetime.now(UTC).replace(tzinfo=None)
if invitation.expires_at < now_utc:
return False
return True
def consume_invitation(
self, code: str, consumed_by_user_id: int, consumed_at: datetime
) -> bool:
"""Mark invitation as consumed."""
invitation = (
self.db.query(ChildInvitation).filter(ChildInvitation.code == code).first()
)
if not invitation:
return False
invitation.is_consumed = True
invitation.consumed_by_user_id = consumed_by_user_id
invitation.consumed_at = consumed_at
self.db.commit()
return True
@@ -1,11 +1,23 @@
"""Dependency injection for repositories."""
from .get_child_invitation_repository import get_child_invitation_repository
from .get_child_repository import get_child_repository
from .get_credentials_repository import get_credentials_repository
from .get_diaper_change_repository import get_diaper_change_repository
from .get_feeding_repository import get_feeding_repository
from .get_invitation_repository import get_invitation_repository
from .get_sleep_repository import get_sleep_repository
from .get_token_repository import get_token_repository
from .get_user_repository import get_user_repository
__all__ = [
"get_user_repository",
"get_token_repository",
"get_child_invitation_repository",
"get_child_repository",
"get_credentials_repository",
"get_diaper_change_repository",
"get_feeding_repository",
"get_invitation_repository",
"get_sleep_repository",
"get_token_repository",
"get_user_repository",
]
@@ -0,0 +1,16 @@
"""Dependency for child invitation repository."""
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from baby_monitor.repositories.child_invitation.sqlite_child_invitation import ( # noqa: E501
SQLiteChildInvitationRepository,
)
from baby_monitor.repositories.dependencies.get_database import get_database
def get_child_invitation_repository(
db: Annotated[Session, Depends(get_database)],
) -> SQLiteChildInvitationRepository:
"""Get child invitation repository instance."""
return SQLiteChildInvitationRepository(db)
@@ -78,10 +78,12 @@ def init_db() -> None:
# This must be done before create_all() is called
from baby_monitor.models.db.user import User # noqa: F401
from baby_monitor.models.db.child import Child # noqa: F401
from baby_monitor.models.db.child_parent import ChildParent # noqa: F401
from baby_monitor.models.db.feeding import Feeding # noqa: F401
from baby_monitor.models.db.diaper_change import DiaperChange # noqa: F401
from baby_monitor.models.db.sleep import Sleep # noqa: F401
from baby_monitor.models.invitation import Invitation # noqa: F401
from baby_monitor.models.child_invitation import ChildInvitation # noqa: F401
# Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True)
@@ -8,6 +8,7 @@ from baby_monitor.repositories.interfaces.diaper_change_repository_interface imp
)
from baby_monitor.models.db.diaper_change import DiaperChange
from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
class SQLiteDiaperChangeRepository(DiaperChangeRepositoryInterface):
@@ -65,8 +66,9 @@ class SQLiteDiaperChangeRepository(DiaperChangeRepositoryInterface):
"""Get all diaper change logs for a specific user's children."""
diaper_changes = (
self.db.query(DiaperChange)
.join(Child)
.filter(Child.user_id == user_id)
.join(Child, DiaperChange.child_id == Child.id)
.join(ChildParent, Child.id == ChildParent.child_id)
.filter(ChildParent.user_id == user_id)
.order_by(DiaperChange.change_time.desc())
.all()
)
@@ -8,6 +8,7 @@ from baby_monitor.repositories.interfaces.feeding_repository_interface import (
)
from baby_monitor.models.db.feeding import Feeding
from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
class SQLiteFeedingRepository(FeedingRepositoryInterface):
@@ -57,8 +58,9 @@ class SQLiteFeedingRepository(FeedingRepositoryInterface):
"""Get all feeding logs for a specific user's children."""
feedings = (
self.db.query(Feeding)
.join(Child)
.filter(Child.user_id == user_id)
.join(Child, Feeding.child_id == Child.id)
.join(ChildParent, Child.id == ChildParent.child_id)
.filter(ChildParent.user_id == user_id)
.order_by(Feeding.start_time.desc())
.all()
)
@@ -1,5 +1,8 @@
"""Repository interfaces package."""
from .child_invitation import (
ChildInvitationRepositoryInterface,
)
from .credentials_repository_interface import (
CredentialsRepositoryInterface,
)
@@ -12,10 +15,27 @@ from .token_repository_interface import (
from .user_repository_interface import (
UserRepositoryInterface,
)
from .child_repository_interface import (
ChildRepositoryInterface,
)
from .feeding_repository_interface import (
FeedingRepositoryInterface,
)
from .diaper_change_repository_interface import (
DiaperChangeRepositoryInterface,
)
from .sleep_repository_interface import (
SleepRepositoryInterface,
)
__all__ = [
"UserRepositoryInterface",
"TokenRepositoryInterface",
"ChildInvitationRepositoryInterface",
"ChildRepositoryInterface",
"CredentialsRepositoryInterface",
"DiaperChangeRepositoryInterface",
"FeedingRepositoryInterface",
"InvitationRepositoryInterface",
"SleepRepositoryInterface",
"TokenRepositoryInterface",
"UserRepositoryInterface",
]
@@ -0,0 +1,36 @@
"""Repository interface for child invitation operations."""
from abc import ABC, abstractmethod
from datetime import datetime
class ChildInvitationRepositoryInterface(ABC):
"""Interface for child invitation repository operations."""
@abstractmethod
def create_invitation(
self,
code: str,
child_id: int,
created_by_user_id: int,
expires_at: datetime,
) -> int:
"""Create a new child invitation."""
pass
@abstractmethod
def get_by_code(self, code: str) -> dict | None:
"""Get invitation by code."""
pass
@abstractmethod
def verify_invitation(self, code: str) -> bool:
"""Verify if an invitation code is valid and not expired."""
pass
@abstractmethod
def consume_invitation(
self, code: str, consumed_by_user_id: int, consumed_at: datetime
) -> bool:
"""Mark invitation as consumed."""
pass
@@ -34,3 +34,15 @@ class ChildRepositoryInterface(ABC):
@abstractmethod
def delete(self, child_id: int) -> bool:
"""Delete a child record."""
@abstractmethod
def get_parent_ids(self, child_id: int) -> list[int]:
"""Get all parent user IDs for a specific child."""
@abstractmethod
def remove_parent(self, child_id: int, user_id: int) -> bool:
"""Remove a parent-child relationship."""
@abstractmethod
def add_parent(self, child_id: int, user_id: int) -> bool:
"""Add a parent-child relationship."""
@@ -17,3 +17,15 @@ class UserRepositoryInterface(ABC):
@abstractmethod
def get_by_id(self, user_id: int) -> dict | None:
"""Get user by ID."""
@abstractmethod
def get_all(self) -> list[dict]:
"""Get all users."""
@abstractmethod
def delete(self, user_id: int) -> bool:
"""Delete a user by ID. Returns True if successful."""
@abstractmethod
def update_password(self, user_id: int, hashed_password: str) -> bool:
"""Update user password. Returns True if successful."""
@@ -8,6 +8,7 @@ from baby_monitor.repositories.interfaces.sleep_repository_interface import (
)
from baby_monitor.models.db.sleep import Sleep
from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
class SQLiteSleepRepository(SleepRepositoryInterface):
@@ -54,8 +55,9 @@ class SQLiteSleepRepository(SleepRepositoryInterface):
"""Get all sleep logs for a specific user's children."""
sleeps = (
self.db.query(Sleep)
.join(Child)
.filter(Child.user_id == user_id)
.join(Child, Sleep.child_id == Child.id)
.join(ChildParent, Child.id == ChildParent.child_id)
.filter(ChildParent.user_id == user_id)
.order_by(Sleep.start_time.desc())
.all()
)
@@ -55,3 +55,35 @@ class SQLiteUserRepository(UserRepositoryInterface):
"created_at": user.created_at,
}
return None
def get_all(self) -> list[dict]:
"""Get all users."""
users = self.db.query(User).all()
return [
{
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"is_admin": user.is_admin,
"created_at": user.created_at,
}
for user in users
]
def delete(self, user_id: int) -> bool:
"""Delete a user by ID. Returns True if successful."""
user = self.db.query(User).filter(User.id == user_id).first()
if user:
self.db.delete(user)
self.db.commit()
return True
return False
def update_password(self, user_id: int, hashed_password: str) -> bool:
"""Update user password. Returns True if successful."""
user = self.db.query(User).filter(User.id == user_id).first()
if user:
user.hashed_password = hashed_password # type: ignore
self.db.commit()
return True
return False
+190 -1
View File
@@ -3,15 +3,35 @@
import secrets
from datetime import datetime, timedelta, UTC
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from baby_monitor.routers.auth import verify_admin
from baby_monitor.repositories.dependencies.get_invitation_repository import (
get_invitation_repository,
)
from baby_monitor.repositories.dependencies.get_user_repository import (
get_user_repository,
)
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.repositories.dependencies.get_feeding_repository import (
get_feeding_repository,
)
from baby_monitor.repositories.dependencies.get_diaper_change_repository import (
get_diaper_change_repository,
)
from baby_monitor.repositories.dependencies.get_sleep_repository import (
get_sleep_repository,
)
from baby_monitor.repositories.interfaces import (
InvitationRepositoryInterface,
UserRepositoryInterface,
ChildRepositoryInterface,
FeedingRepositoryInterface,
DiaperChangeRepositoryInterface,
SleepRepositoryInterface,
)
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -64,3 +84,172 @@ async def generate_invitation_link(
expires_at=expires_at.isoformat(),
message="Invitation link generated successfully. Valid for 24 hours.",
)
class UserResponse(BaseModel):
"""Response model for user data."""
id: int
username: str
is_admin: bool
created_at: datetime
class DeleteUserResponse(BaseModel):
"""Response model for user deletion."""
message: str
deleted_children: list[int]
deleted_feedings: int
deleted_diapers: int
deleted_sleeps: int
@router.get("/users", response_model=list[UserResponse])
async def get_all_users(
admin_id: Annotated[int, Depends(verify_admin)],
user_repository: Annotated[
UserRepositoryInterface,
Depends(get_user_repository),
],
) -> list[UserResponse]:
"""Get all users in the system.
Requires admin role.
Args:
admin_id: Admin user ID (from verify_admin)
user_repository: User storage backend
Returns:
List of all users with their basic information
"""
users = user_repository.get_all()
return [
UserResponse(
id=user["id"],
username=user["username"],
is_admin=user["is_admin"],
created_at=user["created_at"],
)
for user in users
]
@router.delete("/users/{user_id}", response_model=DeleteUserResponse)
async def delete_user(
user_id: int,
admin_id: Annotated[int, Depends(verify_admin)],
user_repository: Annotated[
UserRepositoryInterface,
Depends(get_user_repository),
],
child_repository: Annotated[
ChildRepositoryInterface,
Depends(get_child_repository),
],
feeding_repository: Annotated[
FeedingRepositoryInterface,
Depends(get_feeding_repository),
],
diaper_repository: Annotated[
DiaperChangeRepositoryInterface,
Depends(get_diaper_change_repository),
],
sleep_repository: Annotated[
SleepRepositoryInterface,
Depends(get_sleep_repository),
],
) -> DeleteUserResponse:
"""Delete a user and cascade delete orphaned children and their logs.
When a user is deleted:
1. Find all children belonging to this user
2. For each child, check if other users also have this child
3. Delete only children that have no other parents (orphaned)
4. For orphaned children, delete all associated logs (feedings, diapers, sleep)
5. Finally delete the user
Requires admin role.
Args:
user_id: ID of user to delete
admin_id: Admin user ID (from verify_admin)
user_repository: User storage backend
child_repository: Child storage backend
feeding_repository: Feeding storage backend
diaper_repository: Diaper change storage backend
sleep_repository: Sleep storage backend
Returns:
DeleteUserResponse with deletion summary
Raises:
HTTPException: If user not found or trying to delete yourself
"""
# Prevent admin from deleting themselves
if user_id == admin_id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
# Check if user exists
user = user_repository.get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get all children belonging to this user
children = child_repository.get_by_user_id(user_id)
deleted_children_ids = []
total_feedings_deleted = 0
total_diapers_deleted = 0
total_sleeps_deleted = 0
# For each child, check if they have other parents
for child in children:
child_id = child["id"]
# Remove this user's relationship with the child
child_repository.remove_parent(child_id, user_id)
# Check if child has any remaining parents
remaining_parents = child_repository.get_parent_ids(child_id)
# Only delete child and logs if they have no other parents
if len(remaining_parents) == 0:
# Delete all logs for this orphaned child
feedings = feeding_repository.get_by_child_id(child_id)
for feeding in feedings:
feeding_repository.delete(feeding["id"])
total_feedings_deleted += 1
diapers = diaper_repository.get_by_child_id(child_id)
for diaper in diapers:
diaper_repository.delete(diaper["id"])
total_diapers_deleted += 1
sleeps = sleep_repository.get_by_child_id(child_id)
for sleep in sleeps:
sleep_repository.delete(sleep["id"])
total_sleeps_deleted += 1
# Delete the orphaned child
child_repository.delete(child_id)
deleted_children_ids.append(child_id)
# Finally, delete the user
user_repository.delete(user_id)
message = f"User '{user['username']}' deleted successfully"
if deleted_children_ids:
message += (
f" with {len(deleted_children_ids)} orphaned "
f"child{'ren' if len(deleted_children_ids) != 1 else ''}"
)
return DeleteUserResponse(
message=message,
deleted_children=deleted_children_ids,
deleted_feedings=total_feedings_deleted,
deleted_diapers=total_diapers_deleted,
deleted_sleeps=total_sleeps_deleted,
)
+111 -3
View File
@@ -3,6 +3,7 @@
import secrets
from fastapi import APIRouter, HTTPException, Depends, Header
from typing import Annotated
from pydantic import BaseModel
from baby_monitor.models.auth import (
LoginRequest,
@@ -11,22 +12,35 @@ from baby_monitor.models.auth import (
RegisterResponse,
)
from baby_monitor.repositories import (
get_child_repository,
get_diaper_change_repository,
get_feeding_repository,
get_invitation_repository,
get_sleep_repository,
get_token_repository,
get_user_repository,
)
from baby_monitor.repositories.dependencies.get_invitation_repository import (
get_invitation_repository,
)
from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface,
UserRepositoryInterface,
InvitationRepositoryInterface,
ChildRepositoryInterface,
FeedingRepositoryInterface,
DiaperChangeRepositoryInterface,
SleepRepositoryInterface,
)
from baby_monitor.utils.password import hash_password, verify_password
router = APIRouter(prefix="/api", tags=["authentication"])
class ChangePasswordRequest(BaseModel):
"""Request model for password change."""
current_password: str
new_password: str
def verify_token(
authorization: Annotated[str | None, Header()] = None,
token_repo: TokenRepositoryInterface = Depends(get_token_repository),
@@ -191,3 +205,97 @@ def get_current_user(
"username": user["username"],
"is_admin": user.get("is_admin", False),
}
@router.post("/change-password")
def change_password(
request: ChangePasswordRequest,
user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], # noqa: E501
) -> dict:
"""Change password for the current user."""
# Get user with credentials
user = user_repo.get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verify current password
if not verify_password(request.current_password, user["hashed_password"]):
raise HTTPException(status_code=401, detail="Current password is incorrect")
# Hash new password
new_password_hash = hash_password(request.new_password)
# Update password
success = user_repo.update_password(user_id, new_password_hash)
if not success:
raise HTTPException(status_code=500, detail="Failed to update password")
return {"message": "Password changed successfully"}
@router.delete("/account")
def delete_account(
user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], # noqa: E501
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)], # noqa: E501
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
], # noqa: E501
diaper_repo: Annotated[
DiaperChangeRepositoryInterface, Depends(get_diaper_change_repository)
], # noqa: E501
sleep_repo: Annotated[SleepRepositoryInterface, Depends(get_sleep_repository)], # noqa: E501
) -> dict:
"""Delete current user's account and cascade delete orphaned children."""
# Get user's children
children = child_repo.get_by_user_id(user_id)
deleted_children = []
deleted_feedings = 0
deleted_diapers = 0
deleted_sleeps = 0
# Process each child
for child in children:
child_id = child["id"]
# Remove the parent-child relationship
child_repo.remove_parent(child_id, user_id)
# Check if child has any remaining parents
remaining_parents = child_repo.get_parent_ids(child_id)
if len(remaining_parents) == 0:
# Child is orphaned, delete all logs first
feedings = feeding_repo.get_by_child_id(child_id)
for feeding in feedings:
feeding_repo.delete(feeding["id"])
deleted_feedings += 1
diapers = diaper_repo.get_by_child_id(child_id)
for diaper in diapers:
diaper_repo.delete(diaper["id"])
deleted_diapers += 1
sleeps = sleep_repo.get_by_child_id(child_id)
for sleep in sleeps:
sleep_repo.delete(sleep["id"])
deleted_sleeps += 1
# Now delete the child
child_repo.delete(child_id)
deleted_children.append(child["name"])
# Finally, delete the user
user_repo.delete(user_id)
return {
"message": "Account deleted successfully",
"deleted_children": deleted_children,
"deleted_logs": {
"feedings": deleted_feedings,
"diaper_changes": deleted_diapers,
"sleep_sessions": deleted_sleeps,
},
}
+120 -2
View File
@@ -1,7 +1,10 @@
"""Child management router."""
import secrets
from datetime import datetime, timedelta, UTC
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from baby_monitor.models.child import CreateChildRequest, ChildResponse
from baby_monitor.routers.auth import verify_token
@@ -9,10 +12,35 @@ from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.repositories.dependencies.get_child_invitation_repository import ( # noqa: E501
get_child_invitation_repository,
)
from baby_monitor.repositories.interfaces import ChildInvitationRepositoryInterface # noqa: E501
router = APIRouter(prefix="/api/children", tags=["children"])
class CreateChildInvitationRequest(BaseModel):
"""Request model for creating a child invitation."""
child_id: int
class ChildInvitationResponse(BaseModel):
"""Response model for child invitation."""
code: str
child_id: int
expires_at: str
message: str
class RedeemChildInvitationRequest(BaseModel):
"""Request model for redeeming a child invitation."""
code: str
@router.post("", response_model=ChildResponse, status_code=201)
def create_child(
request: CreateChildRequest,
@@ -53,7 +81,8 @@ def get_child(
raise HTTPException(status_code=404, detail="Child not found")
# Verify the child belongs to the authenticated user
if child["user_id"] != user_id:
parent_ids = child_repo.get_parent_ids(child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied")
return ChildResponse(**child)
@@ -73,7 +102,8 @@ def update_child(
if not child:
raise HTTPException(status_code=404, detail="Child not found")
if child["user_id"] != user_id:
parent_ids = child_repo.get_parent_ids(child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied")
# Update the child
@@ -88,3 +118,91 @@ def update_child(
raise HTTPException(status_code=500, detail="Update failed")
return ChildResponse(**updated_child)
@router.post("/invitations", response_model=ChildInvitationResponse)
def create_child_invitation(
request: CreateChildInvitationRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], # noqa: E501
invitation_repo: Annotated[
ChildInvitationRepositoryInterface, Depends(get_child_invitation_repository)
], # noqa: E501
) -> ChildInvitationResponse:
"""Create an invitation code to share child access with another user."""
# Verify the child exists and user has access
child = child_repo.get_by_id(request.child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
parent_ids = child_repo.get_parent_ids(request.child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied")
# Generate a short invitation code (8 characters)
code = secrets.token_urlsafe(6)[:8].upper()
# Calculate expiration (7 days from now)
expires_at = datetime.now(UTC) + timedelta(days=7)
# Create the invitation
invitation_repo.create_invitation(
code=code,
child_id=request.child_id,
created_by_user_id=user_id,
expires_at=expires_at,
)
return ChildInvitationResponse(
code=code,
child_id=request.child_id,
expires_at=expires_at.isoformat(),
message="Invitation code created. Valid for 7 days.",
)
@router.post("/invitations/redeem")
def redeem_child_invitation(
request: RedeemChildInvitationRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], # noqa: E501
invitation_repo: Annotated[
ChildInvitationRepositoryInterface, Depends(get_child_invitation_repository)
], # noqa: E501
) -> dict:
"""Redeem an invitation code to gain access to a child."""
# Verify the invitation code
if not invitation_repo.verify_invitation(request.code):
raise HTTPException(
status_code=400, detail="Invalid or expired invitation code"
)
# Get the invitation details
invitation = invitation_repo.get_by_code(request.code)
if not invitation:
raise HTTPException(status_code=400, detail="Invalid invitation code")
child_id = invitation["child_id"]
# Check if user already has access to this child
parent_ids = child_repo.get_parent_ids(child_id)
if user_id in parent_ids:
raise HTTPException(
status_code=400, detail="You already have access to this child"
)
# Add user as a parent
child_repo.add_parent(child_id, user_id)
# Consume the invitation
invitation_repo.consume_invitation(request.code, user_id, datetime.now(UTC))
# Get child details
child = child_repo.get_by_id(child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
return {
"message": f"Successfully added access to {child['name']}",
"child": ChildResponse(**child),
}
+5 -15
View File
@@ -19,6 +19,7 @@ from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.utils.access_control import verify_child_access
router = APIRouter(prefix="/api/diaper-changes", tags=["diaper-changes"])
@@ -34,12 +35,7 @@ def create_diaper_change(
) -> DiaperChangeResponse:
"""Create a new diaper change log entry."""
# Verify the child belongs to the authenticated user
child = child_repo.get_by_id(request.child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
if child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied to this child")
verify_child_access(child_repo, request.child_id, user_id)
diaper_change = diaper_repo.create(
child_id=request.child_id,
@@ -81,9 +77,7 @@ def get_diaper_change(
raise HTTPException(status_code=404, detail="Diaper change log not found")
# Verify the diaper change belongs to user's child
child = child_repo.get_by_id(diaper_change["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, diaper_change["child_id"], user_id)
return DiaperChangeResponse(**diaper_change)
@@ -105,9 +99,7 @@ def update_diaper_change(
raise HTTPException(status_code=404, detail="Diaper change log not found")
# Verify the diaper change belongs to user's child
child = child_repo.get_by_id(diaper_change["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, diaper_change["child_id"], user_id)
updated = diaper_repo.update(
diaper_change_id=diaper_change_id,
@@ -142,9 +134,7 @@ def delete_diaper_change(
raise HTTPException(status_code=404, detail="Diaper change log not found")
# Verify the diaper change belongs to user's child
child = child_repo.get_by_id(diaper_change["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, diaper_change["child_id"], user_id)
success = diaper_repo.delete(diaper_change_id)
if not success:
+5 -15
View File
@@ -19,6 +19,7 @@ from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.utils.access_control import verify_child_access
router = APIRouter(prefix="/api/feedings", tags=["feedings"])
@@ -32,12 +33,7 @@ def create_feeding(
) -> FeedingResponse:
"""Create a new feeding log entry."""
# Verify the child belongs to the authenticated user
child = child_repo.get_by_id(request.child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
if child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied to this child")
verify_child_access(child_repo, request.child_id, user_id)
feeding = feeding_repo.create(
child_id=request.child_id,
@@ -95,9 +91,7 @@ def get_feeding(
raise HTTPException(status_code=404, detail="Feeding log not found")
# Verify the feeding belongs to user's child
child = child_repo.get_by_id(feeding["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, feeding["child_id"], user_id)
return FeedingResponse(**feeding)
@@ -117,9 +111,7 @@ def update_feeding(
raise HTTPException(status_code=404, detail="Feeding log not found")
# Verify ownership
child = child_repo.get_by_id(feeding["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, feeding["child_id"], user_id)
# Update the feeding
updated_feeding = feeding_repo.update(
@@ -149,9 +141,7 @@ def delete_feeding(
raise HTTPException(status_code=404, detail="Feeding log not found")
# Verify ownership
child = child_repo.get_by_id(feeding["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, feeding["child_id"], user_id)
success = feeding_repo.delete(feeding_id)
if not success:
+5 -15
View File
@@ -17,6 +17,7 @@ from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.utils.access_control import verify_child_access
router = APIRouter(prefix="/api/sleep", tags=["sleep"])
@@ -30,12 +31,7 @@ def create_sleep(
) -> SleepResponse:
"""Create a new sleep log entry."""
# Verify the child belongs to the authenticated user
child = child_repo.get_by_id(request.child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
if child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied to this child")
verify_child_access(child_repo, request.child_id, user_id)
sleep = sleep_repo.create(
child_id=request.child_id,
@@ -91,9 +87,7 @@ def get_sleep(
raise HTTPException(status_code=404, detail="Sleep log not found")
# Verify the sleep belongs to user's child
child = child_repo.get_by_id(sleep["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, sleep["child_id"], user_id)
return SleepResponse(**sleep)
@@ -113,9 +107,7 @@ def update_sleep(
raise HTTPException(status_code=404, detail="Sleep log not found")
# Verify the sleep belongs to user's child
child = child_repo.get_by_id(sleep["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, sleep["child_id"], user_id)
updated = sleep_repo.update(
sleep_id=sleep_id,
@@ -142,9 +134,7 @@ def delete_sleep(
raise HTTPException(status_code=404, detail="Sleep log not found")
# Verify the sleep belongs to user's child
child = child_repo.get_by_id(sleep["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, sleep["child_id"], user_id)
success = sleep_repo.delete(sleep_id)
if not success:
+349 -1
View File
@@ -185,6 +185,172 @@
background: #c82333;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
.user-management h2::before {
content: "👥";
}
.search-box {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-bottom: 15px;
}
.user-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid #eee;
background: white;
}
.user-item:last-child {
border-bottom: none;
}
.user-item:hover {
background: #f8f9fa;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.user-meta {
font-size: 12px;
color: #666;
}
.admin-badge {
display: inline-block;
padding: 2px 8px;
background: #667eea;
color: white;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
margin-left: 8px;
}
.delete-user-btn {
padding: 6px 12px;
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.delete-user-btn:hover {
background: #c82333;
}
.delete-user-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 8px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.modal h3 {
color: #333;
margin-bottom: 15px;
}
.modal p {
color: #666;
margin-bottom: 20px;
line-height: 1.5;
}
.modal-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.modal-button {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.modal-button.cancel {
background: #6c757d;
color: white;
}
.modal-button.cancel:hover {
background: #5a6268;
}
.modal-button.confirm {
background: #dc3545;
color: white;
}
.modal-button.confirm:hover {
background: #c82333;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
</style>
</head>
<body>
@@ -218,9 +384,46 @@
<div id="error" class="error"></div>
</div>
<div class="section user-management">
<h2>User Management</h2>
<p style="color: #666; margin-bottom: 15px; font-size: 14px">
View and manage all users in the system.
</p>
<input
type="text"
id="userSearch"
class="search-box"
placeholder="Search users by username..."
oninput="filterUsers()"
/>
<div id="userListContainer" class="loading">Loading users...</div>
</div>
<button class="button logout-button" onclick="logout()">Logout</button>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal" id="deleteModal">
<div class="modal-content">
<h3>⚠️ Confirm User Deletion</h3>
<p id="deleteMessage"></p>
<p style="color: #dc3545; font-weight: 600">
This action cannot be undone. All associated children and their logs
(feedings, diapers, sleep) will be permanently deleted.
</p>
<div class="modal-buttons">
<button class="modal-button cancel" onclick="closeDeleteModal()">
Cancel
</button>
<button class="modal-button confirm" onclick="confirmDelete()">
Delete User
</button>
</div>
</div>
</div>
<script>
// Check if user is authenticated and is admin
const token = localStorage.getItem("access_token");
@@ -228,7 +431,10 @@
window.location.href = "/login.html";
}
// Verify user is admin
let allUsers = [];
let userToDelete = null;
// Verify user is admin and load data
fetch("/api/me", {
headers: {
Authorization: `Bearer ${token}`,
@@ -240,12 +446,154 @@
alert("Access denied. Admin privileges required.");
window.location.href = "/";
}
// Load users after verifying admin status
loadUsers();
})
.catch((error) => {
console.error("Error verifying admin status:", error);
window.location.href = "/login.html";
});
async function loadUsers() {
const container = document.getElementById("userListContainer");
try {
const response = await fetch("/api/admin/users", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to load users");
}
allUsers = await response.json();
renderUsers(allUsers);
} catch (error) {
console.error("Error loading users:", error);
container.innerHTML = `
<div class="empty-state">
❌ Failed to load users: ${error.message}
</div>
`;
}
}
function renderUsers(users) {
const container = document.getElementById("userListContainer");
if (users.length === 0) {
container.innerHTML = `
<div class="empty-state">
No users found.
</div>
`;
return;
}
container.className = "user-list";
container.innerHTML = users
.map(
(user) => `
<div class="user-item">
<div class="user-info">
<div class="user-name">
${user.username}
${user.is_admin ? '<span class="admin-badge">ADMIN</span>' : ""}
</div>
<div class="user-meta">
ID: ${user.id} • Created: ${new Date(user.created_at).toLocaleDateString()}
</div>
</div>
<button
class="delete-user-btn"
onclick="showDeleteModal(${user.id}, '${user.username}', ${user.is_admin})"
${user.is_admin ? 'disabled title="Cannot delete admin users"' : ""}
>
Delete
</button>
</div>
`,
)
.join("");
}
function filterUsers() {
const searchTerm = document
.getElementById("userSearch")
.value.toLowerCase();
const filtered = allUsers.filter((user) =>
user.username.toLowerCase().includes(searchTerm),
);
renderUsers(filtered);
}
function showDeleteModal(userId, username, isAdmin) {
if (isAdmin) {
return; // Don't allow deleting admin users
}
userToDelete = { id: userId, username };
const modal = document.getElementById("deleteModal");
const message = document.getElementById("deleteMessage");
message.textContent = `Are you sure you want to delete user "${username}"?`;
modal.classList.add("show");
}
function closeDeleteModal() {
const modal = document.getElementById("deleteModal");
modal.classList.remove("show");
userToDelete = null;
}
async function confirmDelete() {
if (!userToDelete) return;
const modal = document.getElementById("deleteModal");
const confirmBtn = modal.querySelector(".modal-button.confirm");
confirmBtn.disabled = true;
confirmBtn.textContent = "Deleting...";
try {
const response = await fetch(`/api/admin/users/${userToDelete.id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to delete user");
}
const result = await response.json();
// Show success message
alert(
`${result.message}\n\n` +
`Deleted:\n` +
`${result.deleted_children.length} children\n` +
`${result.deleted_feedings} feedings\n` +
`${result.deleted_diapers} diaper changes\n` +
`${result.deleted_sleeps} sleep sessions`,
);
// Reload user list
await loadUsers();
closeDeleteModal();
} catch (error) {
console.error("Error deleting user:", error);
alert(`Failed to delete user: ${error.message}`);
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = "Delete User";
}
}
async function generateLink() {
const btn = document.getElementById("generateBtn");
const linkContainer = document.getElementById("linkContainer");
+6 -124
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Diaper Changes - Baby Monitor</title>
<title>Diapers - Baby Monitor</title>
<link rel="stylesheet" href="/menu.css" />
<style>
* {
margin: 0;
@@ -240,93 +241,9 @@
.edit-button:hover {
background: #d77ee6;
}
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
</style>
</head>
<body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuHome">🏠 Home</div>
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuFeedings">🍼 Feedings</div>
<div class="menu-item" id="menuSleep">😴 Sleep</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container">
<h1>🧷 Diaper Changes</h1>
<p class="subtitle">Overview of all diaper change sessions</p>
@@ -337,6 +254,7 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="/menu.js"></script>
<script>
// Check if user is authenticated
const token = localStorage.getItem("access_token");
@@ -344,50 +262,14 @@
window.location.href = "/login.html";
}
// Initialize burger menu
initBurgerMenu({ includeHome: true });
let allDiaperChanges = [];
let allChildren = [];
let currentDaysRange = 7;
let selectedChildId = null; // null means "All Children"
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuHome").addEventListener("click", () => {
window.location.href = "/";
});
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuFeedings").addEventListener("click", () => {
window.location.href = "/feedings.html";
});
document.getElementById("menuSleep").addEventListener("click", () => {
window.location.href = "/sleep.html";
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Load diaper changes data
async function loadDiaperChanges() {
try {
+5 -123
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Feedings - Baby Monitor</title>
<link rel="stylesheet" href="/menu.css" />
<style>
* {
margin: 0;
@@ -277,93 +278,9 @@
.edit-button:hover {
background: #5568d3;
}
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
</style>
</head>
<body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuHome">🏠 Home</div>
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuDiapers">🧷 Diapers</div>
<div class="menu-item" id="menuSleep">😴 Sleep</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container">
<h1>🍼 Feedings</h1>
<p class="subtitle">Overview of all feeding sessions</p>
@@ -374,6 +291,7 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="/menu.js"></script>
<script>
// Check if user is authenticated
const token = localStorage.getItem("access_token");
@@ -381,50 +299,14 @@
window.location.href = "/login.html";
}
// Initialize burger menu
initBurgerMenu({ includeHome: true });
let allFeedings = [];
let allChildren = [];
let currentDaysRange = 7;
let selectedChildId = null; // null means "All Children"
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuHome").addEventListener("click", () => {
window.location.href = "/";
});
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuDiapers").addEventListener("click", () => {
window.location.href = "/diapers.html";
});
document.getElementById("menuSleep").addEventListener("click", () => {
window.location.href = "/sleep.html";
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Load feedings data
async function loadFeedings() {
try {
+20 -134
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor</title>
<link rel="stylesheet" href="/menu.css" />
<style>
body {
font-family: Arial, sans-serif;
@@ -12,69 +13,6 @@
padding: 0 1rem;
background-color: #f0f0f0;
}
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.admin {
color: #667eea;
font-weight: 600;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
.container {
background: white;
padding: 2rem;
@@ -85,11 +23,10 @@
color: #333;
margin-bottom: 1rem;
}
.user-info {
background: #e3f2fd;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
.greeting {
color: #666;
font-size: 1.1rem;
margin-bottom: 2rem;
}
button {
padding: 0.75rem 1.5rem;
@@ -233,22 +170,6 @@
</style>
</head>
<body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuFeedings">🍼 Feedings</div>
<div class="menu-item" id="menuDiapers">🧷 Diapers</div>
<div class="menu-item" id="menuSleep">😴 Sleep</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container">
<div id="content" class="loading">
<p>Loading...</p>
@@ -314,49 +235,11 @@
</div>
</div>
<script src="/menu.js"></script>
<script>
const token = localStorage.getItem("access_token");
const username = localStorage.getItem("username");
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuFeedings").addEventListener("click", () => {
window.location.href = "/feedings.html";
});
document.getElementById("menuDiapers").addEventListener("click", () => {
window.location.href = "/diapers.html";
});
document.getElementById("menuSleep").addEventListener("click", () => {
window.location.href = "/sleep.html";
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Check if user is logged in
if (!token) {
window.location.href = "/login.html";
@@ -379,6 +262,11 @@
}
})
.then((user) => {
// Initialize burger menu
initBurgerMenu({
includeHome: false,
});
// Check if user has any children
return fetch("/api/children", {
headers: {
@@ -529,7 +417,7 @@
const feedingButtonHtml = activeFeeding
? `
<button class="feeding-button active" onclick="stopFeeding()">
🛑 Stop Feeding
🛑 Stopped Feeding
<div class="feeding-info">
${activeFeeding.child_name || "Child"} - ${formatFeedingType(activeFeeding.feeding_type)}<br>
Started <span id="feedingTimeInfo">${formatTime(activeFeeding.start_time)}</span>
@@ -538,14 +426,14 @@
`
: `
<button class="feeding-button" onclick="showStartFeedingModal()">
▶️ Start Feeding
🍼 Started Feeding
</button>
`;
const sleepButtonHtml = activeSleep
? `
<button class="sleep-button active" onclick="stopSleep()">
🛑 Stop Sleep
🛑 Stopped Sleeping
<div class="sleep-info">
${activeSleep.child_name || "Child"}<br>
Started <span id="sleepTimeInfo">${formatTime(activeSleep.start_time)}</span>
@@ -554,20 +442,18 @@
`
: `
<button class="sleep-button" onclick="showStartSleepModal()">
😴 Start Sleep
😴 Started Sleeping
</button>
`;
document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1>
<div class="user-info">
<p><strong>Logged in as:</strong> ${username}</p>
</div>
<h1>Hi ${username}</h1>
<p class="greeting">Let's keep track of your baby's care.</p>
<button class="feeding-button" onclick="window.location.href='/log-diaper.html'" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); margin-bottom: 20px;">
🧷 Changed Diaper
</button>
${feedingButtonHtml}
${sleepButtonHtml}
<button class="feeding-button" onclick="window.location.href='/log-diaper.html'" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); margin-top: 20px;">
🧷 Log Diaper Change
</button>
`;
}
+73
View File
@@ -0,0 +1,73 @@
/* Burger Menu Styles */
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.admin {
color: #667eea;
font-weight: 600;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Initializes the burger menu navigation
* @param {Object} options - Configuration options
* @param {boolean} options.includeHome - Whether to include Home menu item
* @param {boolean} options.includeFeedings - Whether to include Feedings menu item
* @param {boolean} options.includeDiapers - Whether to include Diapers menu item
* @param {boolean} options.includeSleep - Whether to include Sleep menu item
* @param {boolean} options.includeSettings - Whether to include Settings menu item
*/
function initBurgerMenu(options = {}) {
const {
includeHome = true,
includeFeedings = true,
includeDiapers = true,
includeSleep = true,
includeSettings = true,
} = options;
// Create burger menu HTML
const burgerMenuHTML = `
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
${includeHome ? '<div class="menu-item" id="menuHome">🏠 Home</div>' : ""}
${includeDiapers ? '<div class="menu-item" id="menuDiapers">🧷 Diapers</div>' : ""}
${includeFeedings ? '<div class="menu-item" id="menuFeedings">🍼 Feedings</div>' : ""}
${includeSleep ? '<div class="menu-item" id="menuSleep">😴 Sleep</div>' : ""}
${includeSettings ? '<div class="menu-item" id="menuSettings">⚙️ Settings</div>' : ""}
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
`;
// Insert menu at the beginning of body
document.body.insertAdjacentHTML("afterbegin", burgerMenuHTML);
// Get elements
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
// Toggle menu function
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
// Close menu function
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
// Event listeners for menu toggle
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
// Navigation event listeners
if (includeHome) {
document.getElementById("menuHome")?.addEventListener("click", () => {
window.location.href = "/";
});
}
if (includeFeedings) {
document.getElementById("menuFeedings")?.addEventListener("click", () => {
window.location.href = "/feedings.html";
});
}
if (includeDiapers) {
document.getElementById("menuDiapers")?.addEventListener("click", () => {
window.location.href = "/diapers.html";
});
}
if (includeSleep) {
document.getElementById("menuSleep")?.addEventListener("click", () => {
window.location.href = "/sleep.html";
});
}
if (includeSettings) {
document.getElementById("menuSettings")?.addEventListener("click", () => {
window.location.href = "/settings.html";
});
}
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
// Call the page's logout function if it exists, otherwise use default
if (typeof logout === "function") {
logout();
} else {
defaultLogout();
}
});
// Default logout function
function defaultLogout() {
localStorage.removeItem("access_token");
localStorage.removeItem("username");
window.location.href = "/login.html";
}
}
+845
View File
@@ -0,0 +1,845 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings - Baby Monitor</title>
<link rel="stylesheet" href="/menu.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 800px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.section {
margin-bottom: 40px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.section h2 {
color: #333;
font-size: 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 600;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
text-decoration: none;
display: inline-block;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.button.secondary {
background: #6c757d;
}
.button.secondary:hover {
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.button.success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
}
.button.success:hover {
box-shadow: 0 4px 12px rgba(86, 171, 47, 0.4);
}
.children-list {
display: grid;
gap: 15px;
margin-top: 20px;
}
.child-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.child-card h3 {
color: #333;
margin-bottom: 10px;
font-size: 18px;
}
.child-info {
color: #666;
font-size: 14px;
margin-bottom: 15px;
}
.child-info p {
margin-bottom: 5px;
}
.child-actions {
display: flex;
gap: 10px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.alert {
padding: 12px 20px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.hidden {
display: none;
}
.quick-action {
display: block;
width: 100%;
padding: 15px;
margin-bottom: 15px;
background: white;
border: 2px solid #667eea;
color: #667eea;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.quick-action:hover {
background: #667eea;
color: white;
transform: translateX(5px);
}
</style>
</head>
<body>
<div class="container">
<h1>⚙️ Settings</h1>
<p class="subtitle">Manage your account and children</p>
<div id="alert" class="hidden"></div>
<div class="section">
<h2>👤 Account Information</h2>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" disabled />
</div>
</div>
<div class="section">
<h2>🔑 Change Password</h2>
<form id="userForm">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" />
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" />
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" />
</div>
<button type="submit" class="button" id="saveUserBtn">
💾 Save Changes
</button>
</form>
<div
style="
margin-top: 40px;
padding-top: 30px;
border-top: 2px solid #dc3545;
"
>
<h3 style="color: #dc3545; margin-bottom: 15px">⚠️ Danger Zone</h3>
<p style="color: #666; margin-bottom: 15px; font-size: 14px">
Deleting your account is permanent and cannot be undone. This will:
</p>
<ul
style="
color: #666;
margin-bottom: 20px;
margin-left: 20px;
font-size: 14px;
"
>
<li>Delete your account permanently</li>
<li>Delete all children that have no other parents</li>
<li>
Delete all logs (feedings, diaper changes, sleep) for orphaned
children
</li>
<li>
Remove you as a parent from shared children (they will remain for
other parents)
</li>
</ul>
<button
class="button"
style="
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
"
onclick="confirmDeleteAccount()"
>
🗑️ Delete Account
</button>
</div>
</div>
<div class="section">
<h2>👶 Your Children</h2>
<button
class="quick-action"
onclick="window.location.href='/add-child.html'"
>
Add New Child
</button>
<button
class="quick-action"
onclick="showRedeemCodeModal()"
style="border-color: #56ab2f; color: #56ab2f"
>
🔗 Redeem Share Code
</button>
<div id="childrenContent" class="loading">
<p>Loading children...</p>
</div>
</div>
</div>
<!-- Redeem Code Modal -->
<div
class="modal"
id="redeemCodeModal"
style="
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
"
>
<div
style="
background: white;
padding: 30px;
border-radius: 12px;
max-width: 400px;
width: 90%;
"
>
<h2 style="margin-top: 0">🔗 Redeem Share Code</h2>
<p style="color: #666; margin-bottom: 20px">
Enter the share code to get access to a child.
</p>
<form id="redeemCodeForm">
<div class="form-group">
<label for="shareCode">Share Code</label>
<input
type="text"
id="shareCode"
placeholder="Enter code"
required
style="
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
"
/>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px">
<button
type="button"
class="button secondary"
onclick="closeRedeemCodeModal()"
style="flex: 1"
>
Cancel
</button>
<button type="submit" class="button success" style="flex: 1">
Redeem
</button>
</div>
</form>
</div>
</div>
<!-- Share Code Modal -->
<div
class="modal"
id="shareCodeModal"
style="
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
"
>
<div
style="
background: white;
padding: 30px;
border-radius: 12px;
max-width: 400px;
width: 90%;
"
>
<h2 style="margin-top: 0">🔗 Share Code</h2>
<p style="color: #666; margin-bottom: 20px" id="shareCodeChildName"></p>
<div
style="
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
"
>
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
"
>
<span
style="
font-size: 24px;
font-weight: bold;
color: #333;
letter-spacing: 2px;
"
id="shareCodeValue"
></span>
<button
onclick="copyShareCode()"
style="
background: #667eea;
color: white;
border: none;
padding: 10px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
"
>
📋 Copy
</button>
</div>
</div>
<p style="color: #666; font-size: 14px; margin-bottom: 20px">
Share this code with another user. They can use it in Settings >
Redeem Share Code to get access to this child.
<br /><br />
This code expires in 7 days.
</p>
<button
type="button"
class="button"
onclick="closeShareCodeModal()"
style="width: 100%"
>
Close
</button>
</div>
</div>
<script src="/menu.js"></script>
<script>
const token = localStorage.getItem("access_token");
if (!token) {
window.location.href = "/login.html";
}
// Initialize burger menu
initBurgerMenu({ includeHome: true });
let currentUser = null;
let children = [];
// Load user data and children
async function loadData() {
try {
// Fetch user info
const userResponse = await fetch("/api/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (userResponse.ok) {
currentUser = await userResponse.json();
document.getElementById("username").value = currentUser.username;
} else {
window.location.href = "/login.html";
return;
}
// Fetch children
const childrenResponse = await fetch("/api/children", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (childrenResponse.ok) {
children = await childrenResponse.json();
displayChildren();
} else {
showAlert("Failed to load children", "error");
}
} catch (error) {
console.error("Error loading data:", error);
showAlert("Network error. Please try again.", "error");
}
}
function displayChildren() {
const container = document.getElementById("childrenContent");
if (children.length === 0) {
container.innerHTML = `
<p style="color: #666; text-align: center; padding: 20px;">
No children added yet. Click "Add New Child" to get started.
</p>
`;
return;
}
container.innerHTML = `
<div class="children-list">
${children
.map(
(child) => `
<div class="child-card">
<h3>👶 ${child.name}</h3>
<div class="child-info">
<p><strong>Birth Date:</strong> ${formatDate(child.birth_time)}</p>
<p><strong>Birth Weight:</strong> ${child.birth_weight}g</p>
<p><strong>Added:</strong> ${formatDate(child.created_at)}</p>
</div>
<div class="child-actions">
<button class="button secondary" onclick="editChild(${child.id})">
✏️ Edit
</button>
<button class="button success" onclick="shareChild(${child.id})">
🔗 Share
</button>
</div>
</div>
`,
)
.join("")}
</div>
`;
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function editChild(childId) {
// Redirect to a child edit page or show a modal
// For now, just show an alert
const child = children.find((c) => c.id === childId);
if (child) {
alert(`Edit functionality for ${child.name} coming soon!`);
}
}
async function shareChild(childId) {
const child = children.find((c) => c.id === childId);
if (!child) return;
try {
const response = await fetch("/api/children/invitations", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
child_id: childId,
}),
});
if (!response.ok) {
throw new Error("Failed to create invitation");
}
const invitation = await response.json();
// Show the share code modal
document.getElementById("shareCodeChildName").textContent =
`Share code for ${child.name}`;
document.getElementById("shareCodeValue").textContent =
invitation.code;
window.currentShareCode = invitation.code; // Store for copy function
document.getElementById("shareCodeModal").style.display = "flex";
} catch (error) {
console.error("Error creating share code:", error);
showAlert("Failed to create share code. Please try again.", "error");
}
}
function closeShareCodeModal() {
document.getElementById("shareCodeModal").style.display = "none";
window.currentShareCode = null;
}
function copyShareCode() {
const code = window.currentShareCode;
if (!code) return;
if (navigator.clipboard) {
navigator.clipboard
.writeText(code)
.then(() => {
showAlert("Share code copied to clipboard!", "success");
})
.catch((err) => {
console.error("Failed to copy to clipboard:", err);
showAlert(
"Failed to copy. Please copy the code manually.",
"error",
);
});
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
showAlert("Share code copied to clipboard!", "success");
} catch (err) {
showAlert(
"Failed to copy. Please copy the code manually.",
"error",
);
}
document.body.removeChild(textArea);
}
}
function showRedeemCodeModal() {
document.getElementById("redeemCodeModal").style.display = "flex";
}
function closeRedeemCodeModal() {
document.getElementById("redeemCodeModal").style.display = "none";
document.getElementById("redeemCodeForm").reset();
}
document
.getElementById("redeemCodeForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const code = document.getElementById("shareCode").value.trim();
try {
const response = await fetch("/api/children/invitations/redeem", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ code }),
});
if (!response.ok) {
const error = await response.json();
// Show the specific error message from the backend in a popup
alert(error.detail || "Failed to redeem code");
return;
}
const result = await response.json();
alert(result.message);
closeRedeemCodeModal();
// Reload children list
await loadData();
} catch (error) {
console.error("Error redeeming code:", error);
// Display the actual error message in a popup
alert(error.message);
}
});
async function confirmDeleteAccount() {
const confirmed = confirm(
"⚠️ WARNING: This action cannot be undone!\n\n" +
"Are you absolutely sure you want to delete your account?\n\n" +
"This will:\n" +
"• Delete your account permanently\n" +
"• Delete all children that have no other parents\n" +
"• Delete ALL logs for orphaned children\n" +
"• Remove you as a parent from shared children\n\n" +
'Type "DELETE" in the next prompt to confirm.',
);
if (!confirmed) {
return;
}
const confirmText = prompt("Type DELETE to confirm account deletion:");
if (confirmText !== "DELETE") {
showAlert(
"Account deletion cancelled. You must type DELETE exactly.",
"info",
);
return;
}
try {
const response = await fetch("/api/account", {
method: "DELETE",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
throw new Error("Failed to delete account");
}
const result = await response.json();
// Show deletion summary
let message = "✅ Account deleted successfully!\n\n";
if (result.deleted_children && result.deleted_children.length > 0) {
message += `Deleted children: ${result.deleted_children.join(", ")}\n`;
}
if (result.deleted_logs) {
message += `\nDeleted logs:\n`;
message += `• Feedings: ${result.deleted_logs.feedings}\n`;
message += `• Diaper Changes: ${result.deleted_logs.diaper_changes}\n`;
message += `• Sleep Sessions: ${result.deleted_logs.sleep_sessions}`;
}
alert(message);
// Logout and redirect
localStorage.removeItem("token");
window.location.href = "/login.html";
} catch (error) {
console.error("Error deleting account:", error);
showAlert("Failed to delete account. Please try again.", "error");
}
}
function showAlert(message, type) {
const alert = document.getElementById("alert");
alert.className = `alert alert-${type}`;
alert.textContent = message;
alert.classList.remove("hidden");
setTimeout(() => {
alert.classList.add("hidden");
}, 5000);
}
// Handle user form submission
document
.getElementById("userForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const currentPassword =
document.getElementById("currentPassword").value;
const newPassword = document.getElementById("newPassword").value;
const confirmPassword =
document.getElementById("confirmPassword").value;
// Validate passwords
if (newPassword && newPassword !== confirmPassword) {
alert("New passwords do not match");
return;
}
if (newPassword && !currentPassword) {
alert("Current password is required to change password");
return;
}
// If no password change requested, just show success
if (!newPassword) {
alert("No changes to save");
return;
}
const saveBtn = document.getElementById("saveUserBtn");
saveBtn.disabled = true;
saveBtn.textContent = "Saving...";
try {
const response = await fetch("/api/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to change password");
}
const result = await response.json();
alert(result.message);
// Clear form
document.getElementById("userForm").reset();
} catch (error) {
console.error("Error updating password:", error);
alert(error.message || "Failed to update password");
} finally {
saveBtn.disabled = false;
saveBtn.textContent = "💾 Save Changes";
}
});
// Load data on page load
loadData();
</script>
</body>
</html>
+5 -124
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sleep - Baby Monitor</title>
<link rel="stylesheet" href="/menu.css" />
<style>
* {
margin: 0;
@@ -20,66 +21,6 @@
padding: 20px;
}
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
.container {
background: white;
border-radius: 12px;
@@ -335,23 +276,6 @@
</style>
</head>
<body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuHome">🏠 Home</div>
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuFeedings">🍼 Feedings</div>
<div class="menu-item" id="menuDiapers">🧷 Diapers</div>
<div class="menu-item" id="menuSleep">😴 Sleep</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container">
<h1>😴 Sleep Tracking</h1>
<p class="subtitle">Monitor your baby's sleep patterns</p>
@@ -382,57 +306,18 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="/menu.js"></script>
<script>
const token = localStorage.getItem("access_token");
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuHome").addEventListener("click", () => {
window.location.href = "/";
});
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuFeedings").addEventListener("click", () => {
window.location.href = "/feedings.html";
});
document.getElementById("menuDiapers").addEventListener("click", () => {
window.location.href = "/diapers.html";
});
document.getElementById("menuSleep").addEventListener("click", () => {
closeMenu();
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Check if user is logged in
if (!token) {
window.location.href = "/login.html";
}
// Initialize burger menu
initBurgerMenu({ includeHome: true });
let allSleeps = [];
let allChildren = [];
let selectedChildId = "";
@@ -584,10 +469,6 @@
<h3>Total Sessions</h3>
<div class="value">${totalSessions}</div>
</div>
<div class="stat-card">
<h3>Ongoing</h3>
<div class="value">${ongoingSessions}</div>
</div>
<div class="stat-card">
<h3>Avg Duration</h3>
<div class="value">${formatDuration(avgMinutes)}</div>
+26
View File
@@ -0,0 +1,26 @@
"""Access control utility functions for verifying resource ownership."""
from fastapi import HTTPException
from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
def verify_child_access(
child_repo: SQLiteChildRepository, child_id: int, user_id: int
) -> None:
"""Verify that user has access to the child.
Args:
child_repo: Child repository instance
child_id: ID of the child to verify access for
user_id: ID of the user requesting access
Raises:
HTTPException: 404 if child not found, 403 if user is not a parent
"""
child = child_repo.get_by_id(child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
parent_ids = child_repo.get_parent_ids(child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied to this child")