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

This commit is contained in:
Brian Bjarke Jensen
2025-11-11 00:58:55 +01:00
parent 0d27c47f39
commit d066c6d399
19 changed files with 1361 additions and 25 deletions
+6
View File
@@ -119,6 +119,12 @@ def serve_sleep() -> FileResponse:
return FileResponse(static_path / "sleep.html") 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) @app.get("/menu.css", include_in_schema=False)
def serve_menu_css() -> FileResponse: def serve_menu_css() -> FileResponse:
"""Serve the shared menu CSS.""" """Serve the shared menu CSS."""
@@ -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
)
+10
View File
@@ -1,13 +1,23 @@
"""Repository implementations package.""" """Repository implementations package."""
from baby_monitor.repositories.dependencies import ( from baby_monitor.repositories.dependencies import (
get_child_repository,
get_credentials_repository, get_credentials_repository,
get_diaper_change_repository,
get_feeding_repository,
get_invitation_repository,
get_sleep_repository,
get_token_repository, get_token_repository,
get_user_repository, get_user_repository,
) )
__all__ = [ __all__ = [
"get_child_repository",
"get_credentials_repository", "get_credentials_repository",
"get_diaper_change_repository",
"get_feeding_repository",
"get_invitation_repository",
"get_sleep_repository",
"get_token_repository", "get_token_repository",
"get_user_repository", "get_user_repository",
] ]
@@ -1,6 +1,6 @@
"""SQLite implementation of child repository.""" """SQLite implementation of child repository."""
from datetime import datetime from datetime import datetime, UTC
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces.child_repository_interface import ( from baby_monitor.repositories.interfaces.child_repository_interface import (
@@ -105,6 +105,29 @@ class SQLiteChildRepository(ChildRepositoryInterface):
return True return True
return False 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( def update(
self, self,
child_id: int, child_id: int,
@@ -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.""" """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_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_token_repository import get_token_repository
from .get_user_repository import get_user_repository from .get_user_repository import get_user_repository
__all__ = [ __all__ = [
"get_user_repository", "get_child_invitation_repository",
"get_token_repository", "get_child_repository",
"get_credentials_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)
@@ -83,6 +83,7 @@ def init_db() -> None:
from baby_monitor.models.db.diaper_change import DiaperChange # 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.db.sleep import Sleep # noqa: F401
from baby_monitor.models.invitation import Invitation # 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 # Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
@@ -1,5 +1,8 @@
"""Repository interfaces package.""" """Repository interfaces package."""
from .child_invitation import (
ChildInvitationRepositoryInterface,
)
from .credentials_repository_interface import ( from .credentials_repository_interface import (
CredentialsRepositoryInterface, CredentialsRepositoryInterface,
) )
@@ -26,12 +29,13 @@ from .sleep_repository_interface import (
) )
__all__ = [ __all__ = [
"UserRepositoryInterface", "ChildInvitationRepositoryInterface",
"TokenRepositoryInterface",
"CredentialsRepositoryInterface",
"InvitationRepositoryInterface",
"ChildRepositoryInterface", "ChildRepositoryInterface",
"FeedingRepositoryInterface", "CredentialsRepositoryInterface",
"DiaperChangeRepositoryInterface", "DiaperChangeRepositoryInterface",
"FeedingRepositoryInterface",
"InvitationRepositoryInterface",
"SleepRepositoryInterface", "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
@@ -42,3 +42,7 @@ class ChildRepositoryInterface(ABC):
@abstractmethod @abstractmethod
def remove_parent(self, child_id: int, user_id: int) -> bool: def remove_parent(self, child_id: int, user_id: int) -> bool:
"""Remove a parent-child relationship.""" """Remove a parent-child relationship."""
@abstractmethod
def add_parent(self, child_id: int, user_id: int) -> bool:
"""Add a parent-child relationship."""
@@ -25,3 +25,7 @@ class UserRepositoryInterface(ABC):
@abstractmethod @abstractmethod
def delete(self, user_id: int) -> bool: def delete(self, user_id: int) -> bool:
"""Delete a user by ID. Returns True if successful.""" """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."""
@@ -78,3 +78,12 @@ class SQLiteUserRepository(UserRepositoryInterface):
self.db.commit() self.db.commit()
return True return True
return False 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
+111 -3
View File
@@ -3,6 +3,7 @@
import secrets import secrets
from fastapi import APIRouter, HTTPException, Depends, Header from fastapi import APIRouter, HTTPException, Depends, Header
from typing import Annotated from typing import Annotated
from pydantic import BaseModel
from baby_monitor.models.auth import ( from baby_monitor.models.auth import (
LoginRequest, LoginRequest,
@@ -11,22 +12,35 @@ from baby_monitor.models.auth import (
RegisterResponse, RegisterResponse,
) )
from baby_monitor.repositories import ( 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_token_repository,
get_user_repository, get_user_repository,
) )
from baby_monitor.repositories.dependencies.get_invitation_repository import (
get_invitation_repository,
)
from baby_monitor.repositories.interfaces import ( from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface, TokenRepositoryInterface,
UserRepositoryInterface, UserRepositoryInterface,
InvitationRepositoryInterface, InvitationRepositoryInterface,
ChildRepositoryInterface,
FeedingRepositoryInterface,
DiaperChangeRepositoryInterface,
SleepRepositoryInterface,
) )
from baby_monitor.utils.password import hash_password, verify_password from baby_monitor.utils.password import hash_password, verify_password
router = APIRouter(prefix="/api", tags=["authentication"]) router = APIRouter(prefix="/api", tags=["authentication"])
class ChangePasswordRequest(BaseModel):
"""Request model for password change."""
current_password: str
new_password: str
def verify_token( def verify_token(
authorization: Annotated[str | None, Header()] = None, authorization: Annotated[str | None, Header()] = None,
token_repo: TokenRepositoryInterface = Depends(get_token_repository), token_repo: TokenRepositoryInterface = Depends(get_token_repository),
@@ -191,3 +205,97 @@ def get_current_user(
"username": user["username"], "username": user["username"],
"is_admin": user.get("is_admin", False), "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,
},
}
+116
View File
@@ -1,7 +1,10 @@
"""Child management router.""" """Child management router."""
import secrets
from datetime import datetime, timedelta, UTC
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from baby_monitor.models.child import CreateChildRequest, ChildResponse from baby_monitor.models.child import CreateChildRequest, ChildResponse
from baby_monitor.routers.auth import verify_token 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 ( from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository, 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"]) 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) @router.post("", response_model=ChildResponse, status_code=201)
def create_child( def create_child(
request: CreateChildRequest, request: CreateChildRequest,
@@ -90,3 +118,91 @@ def update_child(
raise HTTPException(status_code=500, detail="Update failed") raise HTTPException(status_code=500, detail="Update failed")
return ChildResponse(**updated_child) 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),
}
+6 -9
View File
@@ -23,11 +23,10 @@
color: #333; color: #333;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.user-info { .greeting {
background: #e3f2fd; color: #666;
padding: 1rem; font-size: 1.1rem;
border-radius: 4px; margin-bottom: 2rem;
margin-bottom: 1rem;
} }
button { button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
@@ -448,10 +447,8 @@
`; `;
document.getElementById("content").innerHTML = ` document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1> <h1>Hi ${username}</h1>
<div class="user-info"> <p class="greeting">Let's keep track of your baby's care.</p>
<p><strong>Logged in as:</strong> ${username}</p>
</div>
<button class="feeding-button" onclick="window.location.href='/log-diaper.html'" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); margin-bottom: 20px;"> <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 🧷 Changed Diaper
</button> </button>
+9 -5
View File
@@ -5,6 +5,7 @@
* @param {boolean} options.includeFeedings - Whether to include Feedings 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.includeDiapers - Whether to include Diapers menu item
* @param {boolean} options.includeSleep - Whether to include Sleep 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 = {}) { function initBurgerMenu(options = {}) {
const { const {
@@ -12,6 +13,7 @@ function initBurgerMenu(options = {}) {
includeFeedings = true, includeFeedings = true,
includeDiapers = true, includeDiapers = true,
includeSleep = true, includeSleep = true,
includeSettings = true,
} = options; } = options;
// Create burger menu HTML // Create burger menu HTML
@@ -29,7 +31,7 @@ function initBurgerMenu(options = {}) {
${includeDiapers ? '<div class="menu-item" id="menuDiapers">🧷 Diapers</div>' : ""} ${includeDiapers ? '<div class="menu-item" id="menuDiapers">🧷 Diapers</div>' : ""}
${includeFeedings ? '<div class="menu-item" id="menuFeedings">🍼 Feedings</div>' : ""} ${includeFeedings ? '<div class="menu-item" id="menuFeedings">🍼 Feedings</div>' : ""}
${includeSleep ? '<div class="menu-item" id="menuSleep">😴 Sleep</div>' : ""} ${includeSleep ? '<div class="menu-item" id="menuSleep">😴 Sleep</div>' : ""}
<div class="menu-item" id="menuAddChild">👶 Add Child</div> ${includeSettings ? '<div class="menu-item" id="menuSettings">⚙️ Settings</div>' : ""}
<div class="menu-item logout" id="menuLogout">🚪 Logout</div> <div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div> </div>
`; `;
@@ -65,10 +67,6 @@ function initBurgerMenu(options = {}) {
}); });
} }
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
if (includeFeedings) { if (includeFeedings) {
document.getElementById("menuFeedings")?.addEventListener("click", () => { document.getElementById("menuFeedings")?.addEventListener("click", () => {
window.location.href = "/feedings.html"; window.location.href = "/feedings.html";
@@ -87,6 +85,12 @@ function initBurgerMenu(options = {}) {
}); });
} }
if (includeSettings) {
document.getElementById("menuSettings")?.addEventListener("click", () => {
window.location.href = "/settings.html";
});
}
document.getElementById("menuLogout").addEventListener("click", () => { document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu(); closeMenu();
// Call the page's logout function if it exists, otherwise use default // Call the page's logout function if it exists, otherwise use default
+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>