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. +

+ + + + +
+
+ + +
+ + + + 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!

-

${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...

+
+ + + + +
+ + + +