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..2b4ae0f 100644
--- a/src/baby_monitor/models/auth.py
+++ b/src/baby_monitor/models/auth.py
@@ -16,4 +16,23 @@ class LoginResponse(BaseModel):
message: str
username: str
access_token: str
+ is_admin: bool
+ 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
+ is_admin: bool
token_type: str = "bearer"
diff --git a/src/baby_monitor/models/db/user.py b/src/baby_monitor/models/db/user.py
index 31f3bdc..c4f4be2 100644
--- a/src/baby_monitor/models/db/user.py
+++ b/src/baby_monitor/models/db/user.py
@@ -1,9 +1,15 @@
"""Database models."""
-from sqlalchemy import Column, Integer, String, DateTime
+from typing import TYPE_CHECKING
+from sqlalchemy import Column, Integer, String, DateTime, Boolean
from datetime import datetime
-from baby_monitor.repositories.dependencies.get_database import Base
+if TYPE_CHECKING:
+ from sqlalchemy.orm import DeclarativeBase
+
+ Base = DeclarativeBase
+else:
+ from baby_monitor.repositories.dependencies.get_database import Base
class User(Base):
@@ -14,6 +20,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..7d381cf
--- /dev/null
+++ b/src/baby_monitor/models/invitation.py
@@ -0,0 +1,37 @@
+"""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 typing import TYPE_CHECKING
+from sqlalchemy import Boolean, DateTime, Integer, String
+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 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..4c32397 100644
--- a/src/baby_monitor/repositories/dependencies/get_database.py
+++ b/src/baby_monitor/repositories/dependencies/get_database.py
@@ -3,9 +3,13 @@
import os
from pathlib import Path
from collections.abc import Generator
+from typing import TYPE_CHECKING
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session
+if TYPE_CHECKING:
+ from sqlalchemy.orm import DeclarativeBase
+
# Database directory
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
@@ -23,11 +27,16 @@ engine = create_engine(
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for declarative models
-Base = declarative_base()
+Base: "type[DeclarativeBase]" = 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..e51882c
--- /dev/null
+++ b/src/baby_monitor/repositories/invitation/sqlite_invitation.py
@@ -0,0 +1,112 @@
+"""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..2f90071
--- /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..c81b940 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,37 @@ 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."""
+ # First try to get user from database
+ user = user_repo.get_by_id(user_id)
+
+ 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 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
+
+ # User not found and not environment admin
+ raise HTTPException(status_code=401, detail="User not found")
+
+
@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,20 +88,38 @@ def login(
Returns user info and access token on successful login.
Raises 401 on invalid credentials.
"""
- # Verify credentials using repository
+ # 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,
+ is_admin=user.get("is_admin", False),
+ )
+
+ # 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(
message="Login successful",
username=credentials.username,
access_token=access_token,
+ is_admin=True,
)
- 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 +131,96 @@ 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,
+ is_admin=user.get("is_admin", False),
+ )
+
+
+@router.get("/me")
+def get_current_user(
+ user_id: Annotated[int, Depends(verify_token)],
+ 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:
+ # 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"],
+ "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..e27d0c9
--- /dev/null
+++ b/src/baby_monitor/static/admin.html
@@ -0,0 +1,319 @@
+
+
+
+
+
+ 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.
+
+
+
+
+
Invitation Link
+
+
+ âšī¸ This link expires in 24 hours and can only be used once. Share it
+ securely with the intended user.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/baby_monitor/static/index.html b/src/baby_monitor/static/index.html
index 1880344..da17c70 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 +142,35 @@
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("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 +182,19 @@
// 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 authenticated content
document.getElementById("content").innerHTML = `
Welcome to Baby Monitor!
Logged in as: ${username}
-
${data.message}
-
+
Your session is active.
`;
-
- // Add logout handler
- document
- .getElementById("logoutBtn")
- .addEventListener("click", logout);
})
.catch((error) => {
console.error("Error:", error);
@@ -115,7 +217,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..eea5bc6 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...
+
+
+
+
đ Create Your Account
+
Complete the registration to get started
+
+
+
+
+
+
+
+
+
â ī¸ Invalid Invitation
+
This invitation link is invalid or has expired
+
+ This could mean:
+
+ - The invitation has already been used
+ - The invitation has expired (24 hours)
+ - The invitation link is incorrect
+
+
+
+ Please contact an administrator for a new invitation link.
+
+
+
+
+
+
+