Merge pull request 'support-adding-users' (#4) from support-adding-users into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 15s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2025-11-07 23:14:38 +01:00
17 changed files with 1348 additions and 28 deletions
+21
View File
@@ -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 router as auth_router
from baby_monitor.routers.auth import verify_token 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.routers.health import router as health_router
from baby_monitor.repositories.dependencies.get_database import init_db 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 # Include routers
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(health_router) app.include_router(health_router)
# Serve HTML pages at root level
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
def serve_home() -> FileResponse: def serve_home() -> FileResponse:
"""Serve the home page (handles auth check client-side).""" """Serve the home page (handles auth check client-side)."""
return FileResponse(static_path / "index.html") 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/") @app.get("/api/")
def read_root(token: Annotated[str, Depends(verify_token)]) -> dict: def read_root(token: Annotated[str, Depends(verify_token)]) -> dict:
"""API root endpoint (requires authentication).""" """API root endpoint (requires authentication)."""
+19
View File
@@ -16,4 +16,23 @@ class LoginResponse(BaseModel):
message: str message: str
username: str username: str
access_token: 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" token_type: str = "bearer"
+9 -2
View File
@@ -1,9 +1,15 @@
"""Database models.""" """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 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): class User(Base):
@@ -14,6 +20,7 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False) username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, 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) created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self) -> str: def __repr__(self) -> str:
+37
View File
@@ -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
)
@@ -3,9 +3,13 @@
import os import os
from pathlib import Path from pathlib import Path
from collections.abc import Generator from collections.abc import Generator
from typing import TYPE_CHECKING
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session from sqlalchemy.orm import declarative_base, sessionmaker, Session
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
# Database directory # Database directory
DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
@@ -23,11 +27,16 @@ engine = create_engine(
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for declarative models # Base class for declarative models
Base = declarative_base() Base: "type[DeclarativeBase]" = declarative_base()
def init_db() -> None: def init_db() -> None:
"""Initialize the database by creating all tables.""" """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 # Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -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)
@@ -3,6 +3,9 @@
from .credentials_repository_interface import ( from .credentials_repository_interface import (
CredentialsRepositoryInterface, CredentialsRepositoryInterface,
) )
from .invitation_repository_interface import (
InvitationRepositoryInterface,
)
from .token_repository_interface import ( from .token_repository_interface import (
TokenRepositoryInterface, TokenRepositoryInterface,
) )
@@ -14,4 +17,5 @@ __all__ = [
"UserRepositoryInterface", "UserRepositoryInterface",
"TokenRepositoryInterface", "TokenRepositoryInterface",
"CredentialsRepositoryInterface", "CredentialsRepositoryInterface",
"InvitationRepositoryInterface",
] ]
@@ -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."""
@@ -0,0 +1,7 @@
"""Invitation repository implementations."""
from baby_monitor.repositories.invitation.sqlite_invitation import (
SQLiteInvitationRepository,
)
__all__ = ["SQLiteInvitationRepository"]
@@ -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()
@@ -20,13 +20,18 @@ class SQLiteUserRepository(UserRepositoryInterface):
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"hashed_password": user.hashed_password, "hashed_password": user.hashed_password,
"is_admin": user.is_admin,
"created_at": user.created_at, "created_at": user.created_at,
} }
return None return None
def create(self, username: str, hashed_password: str) -> dict: def create(self, username: str, hashed_password: str) -> dict:
"""Create a new user.""" """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.add(user)
self.db.commit() self.db.commit()
self.db.refresh(user) self.db.refresh(user)
@@ -34,6 +39,7 @@ class SQLiteUserRepository(UserRepositoryInterface):
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"hashed_password": user.hashed_password, "hashed_password": user.hashed_password,
"is_admin": user.is_admin,
"created_at": user.created_at, "created_at": user.created_at,
} }
@@ -45,6 +51,7 @@ class SQLiteUserRepository(UserRepositoryInterface):
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"hashed_password": user.hashed_password, "hashed_password": user.hashed_password,
"is_admin": user.is_admin,
"created_at": user.created_at, "created_at": user.created_at,
} }
return None return None
+66
View File
@@ -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.",
)
+155 -7
View File
@@ -4,14 +4,25 @@ 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 baby_monitor.models.auth import LoginRequest, LoginResponse from baby_monitor.models.auth import (
LoginRequest,
LoginResponse,
RegisterRequest,
RegisterResponse,
)
from baby_monitor.repositories import ( from baby_monitor.repositories import (
get_token_repository, get_token_repository,
get_credentials_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 ( from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface, TokenRepositoryInterface,
CredentialsRepositoryInterface, CredentialsRepositoryInterface,
UserRepositoryInterface,
InvitationRepositoryInterface,
) )
router = APIRouter(prefix="/api", tags=["authentication"]) router = APIRouter(prefix="/api", tags=["authentication"])
@@ -39,11 +50,37 @@ def verify_token(
return user_id 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) @router.post("/login", response_model=LoginResponse)
def login( def login(
credentials: LoginRequest, credentials: LoginRequest,
token_repo: TokenRepositoryInterface = Depends(get_token_repository), token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)],
creds_repo: CredentialsRepositoryInterface = Depends(get_credentials_repository), user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
creds_repo: Annotated[
CredentialsRepositoryInterface, Depends(get_credentials_repository)
],
) -> LoginResponse: ) -> LoginResponse:
""" """
API endpoint for user authentication. API endpoint for user authentication.
@@ -51,20 +88,38 @@ def login(
Returns user info and access token on successful login. Returns user info and access token on successful login.
Raises 401 on invalid credentials. 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): if creds_repo.verify_admin_credentials(credentials.username, credentials.password):
# Generate a secure random token # Generate a secure random token
access_token = secrets.token_urlsafe(32) 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) token_repo.store(access_token, user_id=1, ttl=3600)
return LoginResponse( return LoginResponse(
message="Login successful", message="Login successful",
username=credentials.username, username=credentials.username,
access_token=access_token, 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") @router.post("/logout")
@@ -76,3 +131,96 @@ def logout(
# Note: We'd need to pass the token itself, not user_id # Note: We'd need to pass the token itself, not user_id
# This is simplified - in production, extract token from verify_token # This is simplified - in production, extract token from verify_token
return {"message": "Logged out successfully"} 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),
}
+319
View File
@@ -0,0 +1,319 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Admin</title>
<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;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 600px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.section {
margin-bottom: 30px;
}
.section h2 {
color: #444;
font-size: 18px;
margin-bottom: 15px;
display: flex;
align-items: center;
}
.section h2::before {
content: "🔗";
margin-right: 8px;
}
.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;
width: 100%;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.button:active {
transform: translateY(0);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.link-container {
display: none;
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 2px solid #667eea;
}
.link-container.show {
display: block;
}
.link-label {
font-size: 12px;
color: #666;
margin-bottom: 8px;
font-weight: 600;
text-transform: uppercase;
}
.link-display {
display: flex;
gap: 10px;
align-items: center;
}
.link-text {
flex: 1;
padding: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 13px;
word-break: break-all;
color: #333;
}
.copy-button {
padding: 10px 16px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
white-space: nowrap;
}
.copy-button:hover {
background: #218838;
}
.copy-button.copied {
background: #155724;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196f3;
padding: 12px;
border-radius: 4px;
margin-top: 15px;
font-size: 14px;
color: #555;
}
.error {
background: #ffebee;
border-left: 4px solid #f44336;
color: #c62828;
padding: 12px;
border-radius: 4px;
margin-top: 15px;
display: none;
}
.error.show {
display: block;
}
.logout-button {
background: #dc3545;
padding: 8px 16px;
font-size: 14px;
margin-top: 20px;
}
.logout-button:hover {
background: #c82333;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
</style>
</head>
<body>
<div class="container">
<h1>👨‍💼 Admin Dashboard</h1>
<p class="subtitle">Manage user invitations and settings</p>
<div class="section">
<h2>Generate Invitation Link</h2>
<p style="color: #666; margin-bottom: 15px; font-size: 14px">
Create a secure one-time link to invite a new user to register.
</p>
<button class="button" id="generateBtn" onclick="generateLink()">
Generate New Invitation Link
</button>
<div id="linkContainer" class="link-container">
<div class="link-label">Invitation Link</div>
<div class="link-display">
<div class="link-text" id="linkText"></div>
<button class="copy-button" id="copyBtn" onclick="copyLink()">
Copy
</button>
</div>
<div class="info-box">
️ This link expires in 24 hours and can only be used once. Share it
securely with the intended user.
</div>
</div>
<div id="error" class="error"></div>
</div>
<button class="button logout-button" onclick="logout()">Logout</button>
</div>
<script>
// Check if user is authenticated and is admin
const token = localStorage.getItem("access_token");
if (!token) {
window.location.href = "/login.html";
}
// Verify user is admin
fetch("/api/me", {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.json())
.then((user) => {
if (!user.is_admin) {
alert("Access denied. Admin privileges required.");
window.location.href = "/";
}
})
.catch((error) => {
console.error("Error verifying admin status:", error);
window.location.href = "/login.html";
});
async function generateLink() {
const btn = document.getElementById("generateBtn");
const linkContainer = document.getElementById("linkContainer");
const linkText = document.getElementById("linkText");
const errorDiv = document.getElementById("error");
// Disable button and show loading state
btn.disabled = true;
btn.textContent = "Generating...";
errorDiv.classList.remove("show");
try {
const response = await fetch("/api/admin/generate-invitation", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Failed to generate link: ${response.statusText}`);
}
const data = await response.json();
// Build the full URL
const baseUrl = window.location.origin;
const inviteUrl = `${baseUrl}/register.html?token=${data.token}`;
// Display the link
linkText.textContent = inviteUrl;
linkContainer.classList.add("show");
} catch (error) {
errorDiv.textContent = `Error: ${error.message}`;
errorDiv.classList.add("show");
} finally {
btn.disabled = false;
btn.textContent = "Generate New Invitation Link";
}
}
function copyLink() {
const linkText = document.getElementById("linkText").textContent;
const copyBtn = document.getElementById("copyBtn");
navigator.clipboard
.writeText(linkText)
.then(() => {
// Show success state
copyBtn.textContent = "✓ Copied!";
copyBtn.classList.add("copied");
// Reset after 2 seconds
setTimeout(() => {
copyBtn.textContent = "Copy";
copyBtn.classList.remove("copied");
}, 2000);
})
.catch((err) => {
alert("Failed to copy link: " + err);
});
}
function logout() {
localStorage.removeItem("access_token");
window.location.href = "/login.html";
}
</script>
</body>
</html>
+116 -14
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Home</title> <title>Baby Monitor</title>
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -12,6 +12,69 @@
padding: 0 1rem; padding: 0 1rem;
background-color: #f0f0f0; background-color: #f0f0f0;
} }
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.admin {
color: #667eea;
font-weight: 600;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
.container { .container {
background: white; background: white;
padding: 2rem; padding: 2rem;
@@ -36,10 +99,20 @@
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
margin-right: 0.5rem;
} }
button:hover { button:hover {
background-color: #c82333; background-color: #c82333;
} }
.admin-button {
background-color: #667eea;
}
.admin-button:hover {
background-color: #5568d3;
}
.hidden {
display: none;
}
.loading { .loading {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
@@ -47,6 +120,18 @@
</style> </style>
</head> </head>
<body> <body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container"> <div class="container">
<div id="content" class="loading"> <div id="content" class="loading">
<p>Loading...</p> <p>Loading...</p>
@@ -57,12 +142,35 @@
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const username = localStorage.getItem("username"); 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 // Check if user is logged in
if (!token) { if (!token) {
window.location.href = "/static/login.html"; window.location.href = "/login.html";
} else { } else {
// Verify token by making an authenticated request // Fetch current user info including admin status
fetch("/api/", { fetch("/api/me", {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
@@ -74,25 +182,19 @@
// Token invalid, redirect to login // Token invalid, redirect to login
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
localStorage.removeItem("username"); localStorage.removeItem("username");
window.location.href = "/static/login.html"; window.location.href = "/login.html";
throw new Error("Authentication failed"); throw new Error("Authentication failed");
} }
}) })
.then((data) => { .then((user) => {
// Show authenticated content // Show authenticated content
document.getElementById("content").innerHTML = ` document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1> <h1>Welcome to Baby Monitor!</h1>
<div class="user-info"> <div class="user-info">
<p><strong>Logged in as:</strong> ${username}</p> <p><strong>Logged in as:</strong> ${username}</p>
</div> </div>
<p>${data.message}</p> <p>Your session is active.</p>
<button id="logoutBtn">Logout</button>
`; `;
// Add logout handler
document
.getElementById("logoutBtn")
.addEventListener("click", logout);
}) })
.catch((error) => { .catch((error) => {
console.error("Error:", error); console.error("Error:", error);
@@ -115,7 +217,7 @@
// Clear local storage and redirect // Clear local storage and redirect
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
localStorage.removeItem("username"); localStorage.removeItem("username");
window.location.href = "/static/login.html"; window.location.href = "/login.html";
} }
} }
</script> </script>
+8 -3
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Login</title> <title>Baby Monitor</title>
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -112,9 +112,14 @@
// Store token in localStorage // Store token in localStorage
localStorage.setItem("access_token", data.access_token); localStorage.setItem("access_token", data.access_token);
localStorage.setItem("username", data.username); localStorage.setItem("username", data.username);
// Redirect to home page
// Redirect based on is_admin from login response
setTimeout(() => { setTimeout(() => {
window.location.href = "/"; if (data.is_admin === true) {
window.location.href = "/admin.html";
} else {
window.location.href = "/";
}
}, 1000); }, 1000);
} else { } else {
showMessage(data.detail || "Login failed", "error"); showMessage(data.detail || "Login failed", "error");
+383
View File
@@ -0,0 +1,383 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Register</title>
<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;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 450px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
text-align: center;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #444;
font-weight: 600;
font-size: 14px;
}
input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #667eea;
}
.button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 14px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
margin-top: 10px;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.button:active {
transform: translateY(0);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.error {
background: #ffebee;
border-left: 4px solid #f44336;
color: #c62828;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
display: none;
font-size: 14px;
}
.error.show {
display: block;
}
.success {
background: #e8f5e9;
border-left: 4px solid #4caf50;
color: #2e7d32;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
display: none;
font-size: 14px;
}
.success.show {
display: block;
}
.loading-container {
text-align: center;
padding: 40px;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196f3;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 14px;
color: #555;
}
.hidden {
display: none;
}
.password-requirements {
font-size: 12px;
color: #666;
margin-top: 5px;
}
.password-requirements ul {
margin: 5px 0 0 20px;
}
</style>
</head>
<body>
<div class="container">
<div id="loadingContainer" class="loading-container">
<div class="spinner"></div>
<p style="color: #666">Validating invitation...</p>
</div>
<div id="registerContainer" class="hidden">
<h1>🎉 Create Your Account</h1>
<p class="subtitle">Complete the registration to get started</p>
<div id="error" class="error"></div>
<div id="success" class="success"></div>
<form id="registerForm" onsubmit="handleRegister(event)">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
required
minlength="3"
autocomplete="username"
placeholder="Choose a username"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
minlength="8"
autocomplete="new-password"
placeholder="Choose a strong password"
/>
<div class="password-requirements">
<ul>
<li>At least 8 characters long</li>
<li>Mix of letters, numbers recommended</li>
</ul>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
minlength="8"
autocomplete="new-password"
placeholder="Re-enter your password"
/>
</div>
<button type="submit" class="button" id="submitBtn">
Create Account
</button>
</form>
</div>
<div id="invalidTokenContainer" class="hidden">
<h1>⚠️ Invalid Invitation</h1>
<p class="subtitle">This invitation link is invalid or has expired</p>
<div class="info-box">
This could mean:
<ul style="margin: 10px 0 0 20px; color: #555">
<li>The invitation has already been used</li>
<li>The invitation has expired (24 hours)</li>
<li>The invitation link is incorrect</li>
</ul>
</div>
<p style="text-align: center; color: #666; margin-top: 20px">
Please contact an administrator for a new invitation link.
</p>
</div>
</div>
<script>
let invitationToken = null;
// Extract token from URL query parameters
const urlParams = new URLSearchParams(window.location.search);
invitationToken = urlParams.get("token");
// Validate token on page load
window.addEventListener("DOMContentLoaded", async () => {
if (!invitationToken) {
showInvalidToken();
return;
}
try {
// Verify the invitation token
const response = await fetch(
`/api/verify-invitation?token=${encodeURIComponent(invitationToken)}`,
);
if (response.ok) {
showRegisterForm();
} else {
showInvalidToken();
}
} catch (error) {
console.error("Error validating invitation:", error);
showInvalidToken();
}
});
function showRegisterForm() {
document.getElementById("loadingContainer").classList.add("hidden");
document.getElementById("registerContainer").classList.remove("hidden");
}
function showInvalidToken() {
document.getElementById("loadingContainer").classList.add("hidden");
document
.getElementById("invalidTokenContainer")
.classList.remove("hidden");
}
async function handleRegister(event) {
event.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const confirmPassword =
document.getElementById("confirmPassword").value;
const errorDiv = document.getElementById("error");
const successDiv = document.getElementById("success");
const submitBtn = document.getElementById("submitBtn");
// Clear previous messages
errorDiv.classList.remove("show");
successDiv.classList.remove("show");
// Validate passwords match
if (password !== confirmPassword) {
errorDiv.textContent = "Passwords do not match!";
errorDiv.classList.add("show");
return;
}
// Disable submit button
submitBtn.disabled = true;
submitBtn.textContent = "Creating Account...";
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: username,
password: password,
invitation_token: invitationToken,
}),
});
const data = await response.json();
if (response.ok) {
// Store the access token and username
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("username", data.username);
// Show success message
successDiv.textContent =
"Account created successfully! Redirecting...";
successDiv.classList.add("show");
// Redirect to home page after a brief delay
setTimeout(() => {
window.location.href = "/";
}, 1500);
} else {
errorDiv.textContent =
data.detail || "Registration failed. Please try again.";
errorDiv.classList.add("show");
submitBtn.disabled = false;
submitBtn.textContent = "Create Account";
}
} catch (error) {
console.error("Registration error:", error);
errorDiv.textContent = "An error occurred. Please try again.";
errorDiv.classList.add("show");
submitBtn.disabled = false;
submitBtn.textContent = "Create Account";
}
}
</script>
</body>
</html>