From d31ebc8c22dad47b403158584201f9024792c2e7 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Fri, 7 Nov 2025 22:33:57 +0100 Subject: [PATCH 1/5] added burger menu with admin page and user invitation link generation --- src/baby_monitor/main.py | 21 + src/baby_monitor/models/auth.py | 17 + src/baby_monitor/models/db/user.py | 3 +- src/baby_monitor/models/invitation.py | 35 ++ .../repositories/dependencies/get_database.py | 5 + .../dependencies/get_invitation_repository.py | 25 ++ .../repositories/interfaces/__init__.py | 4 + .../invitation_repository_interface.py | 49 +++ .../repositories/invitation/__init__.py | 7 + .../invitation/sqlite_invitation.py | 120 ++++++ .../repositories/user/sqlite_user.py | 9 +- src/baby_monitor/routers/admin.py | 66 ++++ src/baby_monitor/routers/auth.py | 161 +++++++- src/baby_monitor/static/admin.html | 315 +++++++++++++++ src/baby_monitor/static/index.html | 144 ++++++- src/baby_monitor/static/login.html | 2 +- src/baby_monitor/static/register.html | 368 ++++++++++++++++++ 17 files changed, 1326 insertions(+), 25 deletions(-) create mode 100644 src/baby_monitor/models/invitation.py create mode 100644 src/baby_monitor/repositories/dependencies/get_invitation_repository.py create mode 100644 src/baby_monitor/repositories/interfaces/invitation_repository_interface.py create mode 100644 src/baby_monitor/repositories/invitation/__init__.py create mode 100644 src/baby_monitor/repositories/invitation/sqlite_invitation.py create mode 100644 src/baby_monitor/routers/admin.py create mode 100644 src/baby_monitor/static/admin.html create mode 100644 src/baby_monitor/static/register.html diff --git a/src/baby_monitor/main.py b/src/baby_monitor/main.py index a7c6e21..c4082dc 100644 --- a/src/baby_monitor/main.py +++ b/src/baby_monitor/main.py @@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles from baby_monitor.routers.auth import router as auth_router from baby_monitor.routers.auth import verify_token +from baby_monitor.routers.admin import router as admin_router from baby_monitor.routers.health import router as health_router from baby_monitor.repositories.dependencies.get_database import init_db @@ -45,15 +46,35 @@ app.mount("/static", StaticFiles(directory=str(static_path)), name="static") # Include routers app.include_router(auth_router) +app.include_router(admin_router) app.include_router(health_router) +# Serve HTML pages at root level @app.get("/", include_in_schema=False) def serve_home() -> FileResponse: """Serve the home page (handles auth check client-side).""" return FileResponse(static_path / "index.html") +@app.get("/login.html", include_in_schema=False) +def serve_login() -> FileResponse: + """Serve the login page.""" + return FileResponse(static_path / "login.html") + + +@app.get("/register.html", include_in_schema=False) +def serve_register() -> FileResponse: + """Serve the registration page.""" + return FileResponse(static_path / "register.html") + + +@app.get("/admin.html", include_in_schema=False) +def serve_admin() -> FileResponse: + """Serve the admin page.""" + return FileResponse(static_path / "admin.html") + + @app.get("/api/") def read_root(token: Annotated[str, Depends(verify_token)]) -> dict: """API root endpoint (requires authentication).""" diff --git a/src/baby_monitor/models/auth.py b/src/baby_monitor/models/auth.py index 62a6a20..2b35530 100644 --- a/src/baby_monitor/models/auth.py +++ b/src/baby_monitor/models/auth.py @@ -17,3 +17,20 @@ class LoginResponse(BaseModel): username: str access_token: str token_type: str = "bearer" + + +class RegisterRequest(BaseModel): + """Request model for user registration.""" + + username: str + password: str + invitation_token: str + + +class RegisterResponse(BaseModel): + """Response model for successful registration.""" + + message: str + username: str + access_token: str + token_type: str = "bearer" diff --git a/src/baby_monitor/models/db/user.py b/src/baby_monitor/models/db/user.py index 31f3bdc..0f01a5a 100644 --- a/src/baby_monitor/models/db/user.py +++ b/src/baby_monitor/models/db/user.py @@ -1,6 +1,6 @@ """Database models.""" -from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy import Column, Integer, String, DateTime, Boolean from datetime import datetime from baby_monitor.repositories.dependencies.get_database import Base @@ -14,6 +14,7 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True, index=True, nullable=False) hashed_password = Column(String, nullable=False) + is_admin = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) def __repr__(self) -> str: diff --git a/src/baby_monitor/models/invitation.py b/src/baby_monitor/models/invitation.py new file mode 100644 index 0000000..68a3089 --- /dev/null +++ b/src/baby_monitor/models/invitation.py @@ -0,0 +1,35 @@ +"""Invitation token model for user registration. + +Note: DateTime(timezone=True) is used for PostgreSQL compatibility. +SQLite will store as naive UTC, which is handled in the repository layer. +""" + +from datetime import datetime +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from baby_monitor.repositories.dependencies.get_database import Base + + +class Invitation(Base): + """Invitation token for new user registration.""" + + __tablename__ = "invitations" + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + token: Mapped[str] = mapped_column(String, unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + created_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False) + is_consumed: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False + ) + consumed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/src/baby_monitor/repositories/dependencies/get_database.py b/src/baby_monitor/repositories/dependencies/get_database.py index 6c9e0b8..cb45567 100644 --- a/src/baby_monitor/repositories/dependencies/get_database.py +++ b/src/baby_monitor/repositories/dependencies/get_database.py @@ -28,6 +28,11 @@ Base = declarative_base() def init_db() -> None: """Initialize the database by creating all tables.""" + # Import models to register them with Base.metadata + # This must be done before create_all() is called + from baby_monitor.models.db.user import User # noqa: F401 + from baby_monitor.models.invitation import Invitation # noqa: F401 + # Ensure data directory exists DATA_DIR.mkdir(parents=True, exist_ok=True) Base.metadata.create_all(bind=engine) diff --git a/src/baby_monitor/repositories/dependencies/get_invitation_repository.py b/src/baby_monitor/repositories/dependencies/get_invitation_repository.py new file mode 100644 index 0000000..fce80f8 --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_invitation_repository.py @@ -0,0 +1,25 @@ +"""Dependency injection for invitation repository.""" + +from typing import Annotated +from fastapi import Depends +from sqlalchemy.orm import Session + +from baby_monitor.repositories.dependencies.get_database import get_database +from baby_monitor.repositories.invitation.sqlite_invitation import ( + SQLiteInvitationRepository, +) + + +def get_invitation_repository( + db: Annotated[Session, Depends(get_database)], +) -> SQLiteInvitationRepository: + """ + Provide an invitation repository instance. + + Args: + db: Database session from dependency injection + + Returns: + SQLiteInvitationRepository instance + """ + return SQLiteInvitationRepository(db) diff --git a/src/baby_monitor/repositories/interfaces/__init__.py b/src/baby_monitor/repositories/interfaces/__init__.py index 0f318da..21a8cac 100644 --- a/src/baby_monitor/repositories/interfaces/__init__.py +++ b/src/baby_monitor/repositories/interfaces/__init__.py @@ -3,6 +3,9 @@ from .credentials_repository_interface import ( CredentialsRepositoryInterface, ) +from .invitation_repository_interface import ( + InvitationRepositoryInterface, +) from .token_repository_interface import ( TokenRepositoryInterface, ) @@ -14,4 +17,5 @@ __all__ = [ "UserRepositoryInterface", "TokenRepositoryInterface", "CredentialsRepositoryInterface", + "InvitationRepositoryInterface", ] diff --git a/src/baby_monitor/repositories/interfaces/invitation_repository_interface.py b/src/baby_monitor/repositories/interfaces/invitation_repository_interface.py new file mode 100644 index 0000000..cd8453c --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/invitation_repository_interface.py @@ -0,0 +1,49 @@ +"""Interface for invitation repository operations.""" + +from abc import ABC, abstractmethod +from datetime import datetime + + +class InvitationRepositoryInterface(ABC): + """Interface for managing invitation tokens.""" + + @abstractmethod + def create_invitation( + self, token: str, created_by_user_id: int, expires_at: datetime + ) -> None: + """ + Create a new invitation token. + + Args: + token: The invitation token string + created_by_user_id: ID of the user creating the invitation + expires_at: Expiration datetime for the invitation + """ + + @abstractmethod + def verify_invitation(self, token: str) -> bool: + """ + Verify if an invitation token is valid and not consumed. + + Args: + token: The invitation token to verify + + Returns: + True if valid and not consumed, False otherwise + """ + + @abstractmethod + def consume_invitation(self, token: str) -> bool: + """ + Mark an invitation token as consumed. + + Args: + token: The invitation token to consume + + Returns: + True if successfully consumed, False if invalid or already used + """ + + @abstractmethod + def cleanup_expired(self) -> None: + """Remove expired invitation tokens from storage.""" diff --git a/src/baby_monitor/repositories/invitation/__init__.py b/src/baby_monitor/repositories/invitation/__init__.py new file mode 100644 index 0000000..2ddabc3 --- /dev/null +++ b/src/baby_monitor/repositories/invitation/__init__.py @@ -0,0 +1,7 @@ +"""Invitation repository implementations.""" + +from baby_monitor.repositories.invitation.sqlite_invitation import ( + SQLiteInvitationRepository, +) + +__all__ = ["SQLiteInvitationRepository"] diff --git a/src/baby_monitor/repositories/invitation/sqlite_invitation.py b/src/baby_monitor/repositories/invitation/sqlite_invitation.py new file mode 100644 index 0000000..6d3260b --- /dev/null +++ b/src/baby_monitor/repositories/invitation/sqlite_invitation.py @@ -0,0 +1,120 @@ +"""SQLite implementation of invitation repository. + +Note: SQLite does not support timezone-aware datetimes natively. +All datetimes are stored as naive UTC and converted at the application layer. +""" + +from datetime import datetime, UTC +from sqlalchemy.orm import Session + +from baby_monitor.models.invitation import Invitation +from baby_monitor.repositories.interfaces.invitation_repository_interface import ( + InvitationRepositoryInterface, +) + + +class SQLiteInvitationRepository(InvitationRepositoryInterface): + """SQLite implementation for managing invitation tokens.""" + + def __init__(self, db: Session) -> None: + """Initialize the repository with a database session.""" + self.db = db + + def create_invitation( + self, token: str, created_by_user_id: int, expires_at: datetime + ) -> None: + """ + Create a new invitation token. + + Args: + token: The invitation token string + created_by_user_id: ID of the user creating the invitation + expires_at: Expiration datetime for the invitation + """ + # Convert timezone-aware datetimes to naive UTC for SQLite + created_at_utc = datetime.now(UTC).replace(tzinfo=None) + expires_at_utc = ( + expires_at.replace(tzinfo=None) + if expires_at.tzinfo + else expires_at + ) + + invitation = Invitation( + token=token, + created_at=created_at_utc, + expires_at=expires_at_utc, + created_by_user_id=created_by_user_id, + is_consumed=False, + consumed_at=None, + ) + self.db.add(invitation) + self.db.commit() + + def verify_invitation(self, token: str) -> bool: + """ + Verify if an invitation token is valid and not consumed. + + Args: + token: The invitation token to verify + + Returns: + True if valid and not consumed, False otherwise + """ + invitation = ( + self.db.query(Invitation).filter(Invitation.token == token).first() + ) + + if not invitation: + 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 + + # Check if already consumed + if invitation.is_consumed: + return False + + return True + + def consume_invitation(self, token: str) -> bool: + """ + Mark an invitation token as consumed. + + Args: + token: The invitation token to consume + + Returns: + True if successfully consumed, False if invalid or already used + """ + invitation = ( + self.db.query(Invitation).filter(Invitation.token == token).first() + ) + + if not invitation: + 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 + + # Check if already consumed + if invitation.is_consumed: + return False + + # Mark as consumed + invitation.is_consumed = True + invitation.consumed_at = now_utc + self.db.commit() + + return True + + def cleanup_expired(self) -> None: + """Remove expired invitation tokens from storage.""" + now_utc = datetime.now(UTC).replace(tzinfo=None) + self.db.query(Invitation).filter( + Invitation.expires_at < now_utc + ).delete() + self.db.commit() diff --git a/src/baby_monitor/repositories/user/sqlite_user.py b/src/baby_monitor/repositories/user/sqlite_user.py index ac7318d..fc64a21 100644 --- a/src/baby_monitor/repositories/user/sqlite_user.py +++ b/src/baby_monitor/repositories/user/sqlite_user.py @@ -20,13 +20,18 @@ class SQLiteUserRepository(UserRepositoryInterface): "id": user.id, "username": user.username, "hashed_password": user.hashed_password, + "is_admin": user.is_admin, "created_at": user.created_at, } return None def create(self, username: str, hashed_password: str) -> dict: """Create a new user.""" - user = User(username=username, hashed_password=hashed_password) + user = User( + username=username, + hashed_password=hashed_password, + is_admin=False, + ) self.db.add(user) self.db.commit() self.db.refresh(user) @@ -34,6 +39,7 @@ class SQLiteUserRepository(UserRepositoryInterface): "id": user.id, "username": user.username, "hashed_password": user.hashed_password, + "is_admin": user.is_admin, "created_at": user.created_at, } @@ -45,6 +51,7 @@ class SQLiteUserRepository(UserRepositoryInterface): "id": user.id, "username": user.username, "hashed_password": user.hashed_password, + "is_admin": user.is_admin, "created_at": user.created_at, } return None diff --git a/src/baby_monitor/routers/admin.py b/src/baby_monitor/routers/admin.py new file mode 100644 index 0000000..4af4a6f --- /dev/null +++ b/src/baby_monitor/routers/admin.py @@ -0,0 +1,66 @@ +"""Admin router for administrative functions.""" + +import secrets +from datetime import datetime, timedelta, UTC +from typing import Annotated +from fastapi import APIRouter, Depends +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.interfaces import ( + InvitationRepositoryInterface, +) + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +class InvitationResponse(BaseModel): + """Response model for invitation link generation.""" + + token: str + expires_at: str + message: str + + +@router.post("/generate-invitation", response_model=InvitationResponse) +async def generate_invitation_link( + user_id: Annotated[int, Depends(verify_admin)], + invitation_repository: Annotated[ + InvitationRepositoryInterface, + Depends(get_invitation_repository), + ], +) -> InvitationResponse: + """Generate a new invitation link for user registration. + + The invitation token is valid for 24 hours and can be used once. + Requires admin role. + + Args: + user_id: Admin user ID (from verify_admin) + invitation_repository: Invitation storage backend + + Returns: + InvitationResponse with token and expiration details + """ + + # Generate secure random token + invitation_token = secrets.token_urlsafe(32) + + # Calculate expiration (24 hours from now) + expires_at = datetime.now(UTC) + timedelta(hours=24) + + # Store invitation token in database + invitation_repository.create_invitation( + token=invitation_token, + created_by_user_id=user_id, + expires_at=expires_at, + ) + + return InvitationResponse( + token=invitation_token, + expires_at=expires_at.isoformat(), + message="Invitation link generated successfully. Valid for 24 hours.", + ) diff --git a/src/baby_monitor/routers/auth.py b/src/baby_monitor/routers/auth.py index 30581e4..bc5f27e 100644 --- a/src/baby_monitor/routers/auth.py +++ b/src/baby_monitor/routers/auth.py @@ -4,14 +4,25 @@ import secrets from fastapi import APIRouter, HTTPException, Depends, Header from typing import Annotated -from baby_monitor.models.auth import LoginRequest, LoginResponse +from baby_monitor.models.auth import ( + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, +) from baby_monitor.repositories import ( get_token_repository, get_credentials_repository, + get_user_repository, +) +from baby_monitor.repositories.dependencies.get_invitation_repository import ( + get_invitation_repository, ) from baby_monitor.repositories.interfaces import ( TokenRepositoryInterface, CredentialsRepositoryInterface, + UserRepositoryInterface, + InvitationRepositoryInterface, ) router = APIRouter(prefix="/api", tags=["authentication"]) @@ -39,11 +50,36 @@ def verify_token( return user_id +def verify_admin( + user_id: Annotated[int, Depends(verify_token)], + user_repo: Annotated[ + UserRepositoryInterface, Depends(get_user_repository) + ], +) -> int: + """Verify the user is an admin and return user_id.""" + user = user_repo.get_by_id(user_id) + + if not user: + raise HTTPException(status_code=401, detail="User not found") + + if not user.get("is_admin", False): + raise HTTPException(status_code=403, detail="Admin access required") + + return user_id + + @router.post("/login", response_model=LoginResponse) def login( credentials: LoginRequest, - token_repo: TokenRepositoryInterface = Depends(get_token_repository), - creds_repo: CredentialsRepositoryInterface = Depends(get_credentials_repository), + token_repo: Annotated[ + TokenRepositoryInterface, Depends(get_token_repository) + ], + user_repo: Annotated[ + UserRepositoryInterface, Depends(get_user_repository) + ], + creds_repo: Annotated[ + CredentialsRepositoryInterface, Depends(get_credentials_repository) + ], ) -> LoginResponse: """ API endpoint for user authentication. @@ -51,11 +87,29 @@ def login( Returns user info and access token on successful login. Raises 401 on invalid credentials. """ - # Verify credentials using repository - if creds_repo.verify_admin_credentials(credentials.username, credentials.password): + # First check if it's a database user + user = user_repo.get_by_username(credentials.username) + if user: + # TODO: Use proper password hashing (bcrypt/argon2) + # For now, compare plain text (matches registration) + if user["hashed_password"] == credentials.password: + # Generate a secure random token + access_token = secrets.token_urlsafe(32) + token_repo.store(access_token, user_id=user["id"], ttl=3600) + + return LoginResponse( + message="Login successful", + username=credentials.username, + access_token=access_token, + ) + + # Fallback to admin credentials from environment + if creds_repo.verify_admin_credentials( + credentials.username, credentials.password + ): # Generate a secure random token access_token = secrets.token_urlsafe(32) - # Store token with user_id (hardcoded 1 for now) + # Store token with user_id (hardcoded 1 for admin) token_repo.store(access_token, user_id=1, ttl=3600) return LoginResponse( @@ -63,8 +117,10 @@ def login( username=credentials.username, access_token=access_token, ) - else: - raise HTTPException(status_code=401, detail="Invalid username or password") + + raise HTTPException( + status_code=401, detail="Invalid username or password" + ) @router.post("/logout") @@ -76,3 +132,92 @@ def logout( # Note: We'd need to pass the token itself, not user_id # This is simplified - in production, extract token from verify_token return {"message": "Logged out successfully"} + + +@router.get("/verify-invitation") +def verify_invitation( + token: str, + invitation_repo: Annotated[ + InvitationRepositoryInterface, Depends(get_invitation_repository) + ], +) -> dict[str, bool]: + """Verify if an invitation token is valid.""" + is_valid = invitation_repo.verify_invitation(token) + if not is_valid: + raise HTTPException(status_code=400, detail="Invalid or expired invitation") + return {"valid": True} + + +@router.post("/register", response_model=RegisterResponse) +def register( + request: RegisterRequest, + user_repo: Annotated[ + UserRepositoryInterface, Depends(get_user_repository) + ], + invitation_repo: Annotated[ + InvitationRepositoryInterface, Depends(get_invitation_repository) + ], + token_repo: Annotated[ + TokenRepositoryInterface, Depends(get_token_repository) + ], +) -> RegisterResponse: + """ + Register a new user with an invitation token. + + Verifies the invitation, creates the user, consumes the invitation, + and returns an authentication token. + """ + # Verify invitation token + if not invitation_repo.verify_invitation(request.invitation_token): + raise HTTPException( + status_code=400, + detail="Invalid or expired invitation token", + ) + + # Check if username already exists + existing_user = user_repo.get_by_username(request.username) + if existing_user: + raise HTTPException( + status_code=400, + detail="Username already exists", + ) + + # Create the new user + # TODO: Hash password before storing (currently plain text) + user = user_repo.create( + username=request.username, + hashed_password=request.password, + ) + + # Consume the invitation token + invitation_repo.consume_invitation(request.invitation_token) + + # Generate authentication token + access_token = secrets.token_urlsafe(32) + token_repo.store(access_token, user_id=user["id"], ttl=3600) + + return RegisterResponse( + message="Registration successful", + username=user["username"], + access_token=access_token, + ) + + +@router.get("/me") +def get_current_user( + user_id: Annotated[int, Depends(verify_token)], + user_repo: Annotated[ + UserRepositoryInterface, Depends(get_user_repository) + ], +) -> dict: + """Get current user info including admin status.""" + user = user_repo.get_by_id(user_id) + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return { + "id": user["id"], + "username": user["username"], + "is_admin": user.get("is_admin", False), + } diff --git a/src/baby_monitor/static/admin.html b/src/baby_monitor/static/admin.html new file mode 100644 index 0000000..bd4d956 --- /dev/null +++ b/src/baby_monitor/static/admin.html @@ -0,0 +1,315 @@ + + + + + + Baby Monitor - Admin + + + +
+

👨‍đŸ’ŧ Admin Dashboard

+

Manage user invitations and settings

+ +
+

Generate Invitation Link

+

+ Create a secure one-time link to invite a new user to register. +

+ + + + +
+
+ + +
+ + + + diff --git a/src/baby_monitor/static/index.html b/src/baby_monitor/static/index.html index 1880344..87b3575 100644 --- a/src/baby_monitor/static/index.html +++ b/src/baby_monitor/static/index.html @@ -3,7 +3,7 @@ - Baby Monitor - Home + Baby Monitor +
+
+
+
+
+ + + + +

Loading...

@@ -57,12 +147,39 @@ 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("menuAdmin").addEventListener("click", () => { + window.location.href = "/admin.html"; + }); + + document.getElementById("menuLogout").addEventListener("click", () => { + closeMenu(); + logout(); + }); + // Check if user is logged in if (!token) { - window.location.href = "/static/login.html"; + window.location.href = "/login.html"; } else { - // Verify token by making an authenticated request - fetch("/api/", { + // Fetch current user info including admin status + fetch("/api/me", { headers: { Authorization: `Bearer ${token}`, }, @@ -74,25 +191,24 @@ // Token invalid, redirect to login localStorage.removeItem("access_token"); localStorage.removeItem("username"); - window.location.href = "/static/login.html"; + window.location.href = "/login.html"; throw new Error("Authentication failed"); } }) - .then((data) => { + .then((user) => { + // Show admin menu item if user is admin + if (user.is_admin) { + document.getElementById("menuAdmin").classList.remove("hidden"); + } + // Show authenticated content document.getElementById("content").innerHTML = `

Welcome to Baby Monitor!

-

${data.message}

- +

Your session is active.

`; - - // Add logout handler - document - .getElementById("logoutBtn") - .addEventListener("click", logout); }) .catch((error) => { console.error("Error:", error); @@ -115,7 +231,7 @@ // Clear local storage and redirect localStorage.removeItem("access_token"); localStorage.removeItem("username"); - window.location.href = "/static/login.html"; + window.location.href = "/login.html"; } } diff --git a/src/baby_monitor/static/login.html b/src/baby_monitor/static/login.html index 11b96e0..8b298c2 100644 --- a/src/baby_monitor/static/login.html +++ b/src/baby_monitor/static/login.html @@ -3,7 +3,7 @@ - Baby Monitor - Login + Baby Monitor + + +
+
+
+

Validating invitation...

+
+ + + + +
+ + + + From 43971a1eea1cbe414f7673c7854fbb027e0ea459 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Fri, 7 Nov 2025 23:04:21 +0100 Subject: [PATCH 2/5] added admin login redirect to admin dashboard --- src/baby_monitor/models/auth.py | 2 ++ src/baby_monitor/routers/auth.py | 33 ++++++++++++++++++++++++------ src/baby_monitor/static/login.html | 9 ++++++-- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/baby_monitor/models/auth.py b/src/baby_monitor/models/auth.py index 2b35530..2b4ae0f 100644 --- a/src/baby_monitor/models/auth.py +++ b/src/baby_monitor/models/auth.py @@ -16,6 +16,7 @@ class LoginResponse(BaseModel): message: str username: str access_token: str + is_admin: bool token_type: str = "bearer" @@ -33,4 +34,5 @@ class RegisterResponse(BaseModel): message: str username: str access_token: str + is_admin: bool token_type: str = "bearer" diff --git a/src/baby_monitor/routers/auth.py b/src/baby_monitor/routers/auth.py index bc5f27e..43edc48 100644 --- a/src/baby_monitor/routers/auth.py +++ b/src/baby_monitor/routers/auth.py @@ -57,15 +57,24 @@ def verify_admin( ], ) -> int: """Verify the user is an admin and return user_id.""" + # First try to get user from database user = user_repo.get_by_id(user_id) - if not user: - raise HTTPException(status_code=401, detail="User not found") + if user: + # Database user - check is_admin field + if not user.get("is_admin", False): + raise HTTPException( + status_code=403, detail="Admin access required" + ) + return user_id - if not user.get("is_admin", False): - raise HTTPException(status_code=403, detail="Admin access required") + # If not in database but has valid token with user_id=1, + # it's the environment-based admin (only assigned during env admin login) + if user_id == 1: + return user_id - return user_id + # User not found and not environment admin + raise HTTPException(status_code=401, detail="User not found") @router.post("/login", response_model=LoginResponse) @@ -101,6 +110,7 @@ def login( message="Login successful", username=credentials.username, access_token=access_token, + is_admin=user.get("is_admin", False), ) # Fallback to admin credentials from environment @@ -116,6 +126,7 @@ def login( message="Login successful", username=credentials.username, access_token=access_token, + is_admin=True, ) raise HTTPException( @@ -200,6 +211,7 @@ def register( message="Registration successful", username=user["username"], access_token=access_token, + is_admin=user.get("is_admin", False), ) @@ -209,12 +221,21 @@ def get_current_user( user_repo: Annotated[ UserRepositoryInterface, Depends(get_user_repository) ], + creds_repo: Annotated[ + CredentialsRepositoryInterface, Depends(get_credentials_repository) + ], ) -> dict: """Get current user info including admin status.""" user = user_repo.get_by_id(user_id) + # If user not found in DB, might be env-based admin if not user: - raise HTTPException(status_code=404, detail="User not found") + # Return admin info from environment + return { + "id": user_id, + "username": creds_repo.get_admin_username(), + "is_admin": True, # Env-based admin is always admin + } return { "id": user["id"], diff --git a/src/baby_monitor/static/login.html b/src/baby_monitor/static/login.html index 8b298c2..8d2ca9e 100644 --- a/src/baby_monitor/static/login.html +++ b/src/baby_monitor/static/login.html @@ -112,9 +112,14 @@ // Store token in localStorage localStorage.setItem("access_token", data.access_token); localStorage.setItem("username", data.username); - // Redirect to home page + + // Redirect based on is_admin from login response setTimeout(() => { - window.location.href = "/"; + if (data.is_admin === true) { + window.location.href = "/admin.html"; + } else { + window.location.href = "/"; + } }, 1000); } else { showMessage(data.detail || "Login failed", "error"); From 9d9bab56ede0b9951b6b9eb26c7ea728d1e08795 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Fri, 7 Nov 2025 23:05:40 +0100 Subject: [PATCH 3/5] removed admin link from burger menu --- src/baby_monitor/static/index.html | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/baby_monitor/static/index.html b/src/baby_monitor/static/index.html index 87b3575..2e41d37 100644 --- a/src/baby_monitor/static/index.html +++ b/src/baby_monitor/static/index.html @@ -129,9 +129,6 @@ - + - +
@@ -148,20 +146,20 @@ 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("menuLogout").addEventListener("click", () => { closeMenu(); logout(); diff --git a/src/baby_monitor/static/login.html b/src/baby_monitor/static/login.html index 8d2ca9e..eea5bc6 100644 --- a/src/baby_monitor/static/login.html +++ b/src/baby_monitor/static/login.html @@ -112,7 +112,7 @@ // Store token in localStorage localStorage.setItem("access_token", data.access_token); localStorage.setItem("username", data.username); - + // Redirect based on is_admin from login response setTimeout(() => { if (data.is_admin === true) { diff --git a/src/baby_monitor/static/register.html b/src/baby_monitor/static/register.html index 60f1a78..8e3aea0 100644 --- a/src/baby_monitor/static/register.html +++ b/src/baby_monitor/static/register.html @@ -1,368 +1,383 @@ - + - - - + + + Baby Monitor - Register - - + +
-
-
-

Validating invitation...

-
+
+
+

Validating invitation...

+
-