added burger menu with admin page and user invitation link generation

This commit is contained in:
Brian Bjarke Jensen
2025-11-07 22:33:57 +01:00
parent b8e04fe816
commit d31ebc8c22
17 changed files with 1326 additions and 25 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 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)."""
+17
View File
@@ -17,3 +17,20 @@ class LoginResponse(BaseModel):
username: str
access_token: str
token_type: str = "bearer"
class RegisterRequest(BaseModel):
"""Request model for user registration."""
username: str
password: str
invitation_token: str
class RegisterResponse(BaseModel):
"""Response model for successful registration."""
message: str
username: str
access_token: str
token_type: str = "bearer"
+2 -1
View File
@@ -1,6 +1,6 @@
"""Database models."""
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from datetime import datetime
from baby_monitor.repositories.dependencies.get_database import Base
@@ -14,6 +14,7 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self) -> str:
+35
View File
@@ -0,0 +1,35 @@
"""Invitation token model for user registration.
Note: DateTime(timezone=True) is used for PostgreSQL compatibility.
SQLite will store as naive UTC, which is handled in the repository layer.
"""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from baby_monitor.repositories.dependencies.get_database import Base
class Invitation(Base):
"""Invitation token for new user registration."""
__tablename__ = "invitations"
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
token: Mapped[str] = mapped_column(String, unique=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
created_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
is_consumed: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
consumed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
@@ -28,6 +28,11 @@ Base = declarative_base()
def init_db() -> None:
"""Initialize the database by creating all tables."""
# Import models to register them with Base.metadata
# This must be done before create_all() is called
from baby_monitor.models.db.user import User # noqa: F401
from baby_monitor.models.invitation import Invitation # noqa: F401
# Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine)
@@ -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 (
CredentialsRepositoryInterface,
)
from .invitation_repository_interface import (
InvitationRepositoryInterface,
)
from .token_repository_interface import (
TokenRepositoryInterface,
)
@@ -14,4 +17,5 @@ __all__ = [
"UserRepositoryInterface",
"TokenRepositoryInterface",
"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,120 @@
"""SQLite implementation of invitation repository.
Note: SQLite does not support timezone-aware datetimes natively.
All datetimes are stored as naive UTC and converted at the application layer.
"""
from datetime import datetime, UTC
from sqlalchemy.orm import Session
from baby_monitor.models.invitation import Invitation
from baby_monitor.repositories.interfaces.invitation_repository_interface import (
InvitationRepositoryInterface,
)
class SQLiteInvitationRepository(InvitationRepositoryInterface):
"""SQLite implementation for managing invitation tokens."""
def __init__(self, db: Session) -> None:
"""Initialize the repository with a database session."""
self.db = db
def create_invitation(
self, token: str, created_by_user_id: int, expires_at: datetime
) -> None:
"""
Create a new invitation token.
Args:
token: The invitation token string
created_by_user_id: ID of the user creating the invitation
expires_at: Expiration datetime for the invitation
"""
# Convert timezone-aware datetimes to naive UTC for SQLite
created_at_utc = datetime.now(UTC).replace(tzinfo=None)
expires_at_utc = (
expires_at.replace(tzinfo=None)
if expires_at.tzinfo
else expires_at
)
invitation = Invitation(
token=token,
created_at=created_at_utc,
expires_at=expires_at_utc,
created_by_user_id=created_by_user_id,
is_consumed=False,
consumed_at=None,
)
self.db.add(invitation)
self.db.commit()
def verify_invitation(self, token: str) -> bool:
"""
Verify if an invitation token is valid and not consumed.
Args:
token: The invitation token to verify
Returns:
True if valid and not consumed, False otherwise
"""
invitation = (
self.db.query(Invitation).filter(Invitation.token == token).first()
)
if not invitation:
return False
# Compare as naive UTC datetimes (SQLite stores without timezone)
now_utc = datetime.now(UTC).replace(tzinfo=None)
if invitation.expires_at < now_utc:
return False
# Check if already consumed
if invitation.is_consumed:
return False
return True
def consume_invitation(self, token: str) -> bool:
"""
Mark an invitation token as consumed.
Args:
token: The invitation token to consume
Returns:
True if successfully consumed, False if invalid or already used
"""
invitation = (
self.db.query(Invitation).filter(Invitation.token == token).first()
)
if not invitation:
return False
# Compare as naive UTC datetimes (SQLite stores without timezone)
now_utc = datetime.now(UTC).replace(tzinfo=None)
if invitation.expires_at < now_utc:
return False
# Check if already consumed
if invitation.is_consumed:
return False
# Mark as consumed
invitation.is_consumed = True
invitation.consumed_at = now_utc
self.db.commit()
return True
def cleanup_expired(self) -> None:
"""Remove expired invitation tokens from storage."""
now_utc = datetime.now(UTC).replace(tzinfo=None)
self.db.query(Invitation).filter(
Invitation.expires_at < now_utc
).delete()
self.db.commit()
@@ -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
+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.",
)
+153 -8
View File
@@ -4,14 +4,25 @@ import secrets
from fastapi import APIRouter, HTTPException, Depends, Header
from typing import Annotated
from baby_monitor.models.auth import LoginRequest, LoginResponse
from baby_monitor.models.auth import (
LoginRequest,
LoginResponse,
RegisterRequest,
RegisterResponse,
)
from baby_monitor.repositories import (
get_token_repository,
get_credentials_repository,
get_user_repository,
)
from baby_monitor.repositories.dependencies.get_invitation_repository import (
get_invitation_repository,
)
from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface,
CredentialsRepositoryInterface,
UserRepositoryInterface,
InvitationRepositoryInterface,
)
router = APIRouter(prefix="/api", tags=["authentication"])
@@ -39,11 +50,36 @@ def verify_token(
return user_id
def verify_admin(
user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[
UserRepositoryInterface, Depends(get_user_repository)
],
) -> int:
"""Verify the user is an admin and return user_id."""
user = user_repo.get_by_id(user_id)
if not user:
raise HTTPException(status_code=401, detail="User not found")
if not user.get("is_admin", False):
raise HTTPException(status_code=403, detail="Admin access required")
return user_id
@router.post("/login", response_model=LoginResponse)
def login(
credentials: LoginRequest,
token_repo: TokenRepositoryInterface = Depends(get_token_repository),
creds_repo: CredentialsRepositoryInterface = Depends(get_credentials_repository),
token_repo: Annotated[
TokenRepositoryInterface, Depends(get_token_repository)
],
user_repo: Annotated[
UserRepositoryInterface, Depends(get_user_repository)
],
creds_repo: Annotated[
CredentialsRepositoryInterface, Depends(get_credentials_repository)
],
) -> LoginResponse:
"""
API endpoint for user authentication.
@@ -51,11 +87,29 @@ def login(
Returns user info and access token on successful login.
Raises 401 on invalid credentials.
"""
# Verify credentials using repository
if creds_repo.verify_admin_credentials(credentials.username, credentials.password):
# First check if it's a database user
user = user_repo.get_by_username(credentials.username)
if user:
# TODO: Use proper password hashing (bcrypt/argon2)
# For now, compare plain text (matches registration)
if user["hashed_password"] == credentials.password:
# Generate a secure random token
access_token = secrets.token_urlsafe(32)
token_repo.store(access_token, user_id=user["id"], ttl=3600)
return LoginResponse(
message="Login successful",
username=credentials.username,
access_token=access_token,
)
# Fallback to admin credentials from environment
if creds_repo.verify_admin_credentials(
credentials.username, credentials.password
):
# Generate a secure random token
access_token = secrets.token_urlsafe(32)
# Store token with user_id (hardcoded 1 for now)
# Store token with user_id (hardcoded 1 for admin)
token_repo.store(access_token, user_id=1, ttl=3600)
return LoginResponse(
@@ -63,8 +117,10 @@ def login(
username=credentials.username,
access_token=access_token,
)
else:
raise HTTPException(status_code=401, detail="Invalid username or password")
raise HTTPException(
status_code=401, detail="Invalid username or password"
)
@router.post("/logout")
@@ -76,3 +132,92 @@ def logout(
# Note: We'd need to pass the token itself, not user_id
# This is simplified - in production, extract token from verify_token
return {"message": "Logged out successfully"}
@router.get("/verify-invitation")
def verify_invitation(
token: str,
invitation_repo: Annotated[
InvitationRepositoryInterface, Depends(get_invitation_repository)
],
) -> dict[str, bool]:
"""Verify if an invitation token is valid."""
is_valid = invitation_repo.verify_invitation(token)
if not is_valid:
raise HTTPException(status_code=400, detail="Invalid or expired invitation")
return {"valid": True}
@router.post("/register", response_model=RegisterResponse)
def register(
request: RegisterRequest,
user_repo: Annotated[
UserRepositoryInterface, Depends(get_user_repository)
],
invitation_repo: Annotated[
InvitationRepositoryInterface, Depends(get_invitation_repository)
],
token_repo: Annotated[
TokenRepositoryInterface, Depends(get_token_repository)
],
) -> RegisterResponse:
"""
Register a new user with an invitation token.
Verifies the invitation, creates the user, consumes the invitation,
and returns an authentication token.
"""
# Verify invitation token
if not invitation_repo.verify_invitation(request.invitation_token):
raise HTTPException(
status_code=400,
detail="Invalid or expired invitation token",
)
# Check if username already exists
existing_user = user_repo.get_by_username(request.username)
if existing_user:
raise HTTPException(
status_code=400,
detail="Username already exists",
)
# Create the new user
# TODO: Hash password before storing (currently plain text)
user = user_repo.create(
username=request.username,
hashed_password=request.password,
)
# Consume the invitation token
invitation_repo.consume_invitation(request.invitation_token)
# Generate authentication token
access_token = secrets.token_urlsafe(32)
token_repo.store(access_token, user_id=user["id"], ttl=3600)
return RegisterResponse(
message="Registration successful",
username=user["username"],
access_token=access_token,
)
@router.get("/me")
def get_current_user(
user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[
UserRepositoryInterface, Depends(get_user_repository)
],
) -> dict:
"""Get current user info including admin status."""
user = user_repo.get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": user["id"],
"username": user["username"],
"is_admin": user.get("is_admin", False),
}
+315
View File
@@ -0,0 +1,315 @@
<!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>
+130 -14
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Home</title>
<title>Baby Monitor</title>
<style>
body {
font-family: Arial, sans-serif;
@@ -12,6 +12,69 @@
padding: 0 1rem;
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 {
background: white;
padding: 2rem;
@@ -36,10 +99,20 @@
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
margin-right: 0.5rem;
}
button:hover {
background-color: #c82333;
}
.admin-button {
background-color: #667eea;
}
.admin-button:hover {
background-color: #5568d3;
}
.hidden {
display: none;
}
.loading {
text-align: center;
padding: 2rem;
@@ -47,6 +120,23 @@
</style>
</head>
<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 admin hidden" id="menuAdmin">
👨‍💼 Admin Dashboard
</div>
<div class="menu-item logout" id="menuLogout">
🚪 Logout
</div>
</div>
<div class="container">
<div id="content" class="loading">
<p>Loading...</p>
@@ -57,12 +147,39 @@
const token = localStorage.getItem("access_token");
const username = localStorage.getItem("username");
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuAdmin").addEventListener("click", () => {
window.location.href = "/admin.html";
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Check if user is logged in
if (!token) {
window.location.href = "/static/login.html";
window.location.href = "/login.html";
} else {
// Verify token by making an authenticated request
fetch("/api/", {
// Fetch current user info including admin status
fetch("/api/me", {
headers: {
Authorization: `Bearer ${token}`,
},
@@ -74,25 +191,24 @@
// Token invalid, redirect to login
localStorage.removeItem("access_token");
localStorage.removeItem("username");
window.location.href = "/static/login.html";
window.location.href = "/login.html";
throw new Error("Authentication failed");
}
})
.then((data) => {
.then((user) => {
// Show admin menu item if user is admin
if (user.is_admin) {
document.getElementById("menuAdmin").classList.remove("hidden");
}
// Show authenticated content
document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1>
<div class="user-info">
<p><strong>Logged in as:</strong> ${username}</p>
</div>
<p>${data.message}</p>
<button id="logoutBtn">Logout</button>
<p>Your session is active.</p>
`;
// Add logout handler
document
.getElementById("logoutBtn")
.addEventListener("click", logout);
})
.catch((error) => {
console.error("Error:", error);
@@ -115,7 +231,7 @@
// Clear local storage and redirect
localStorage.removeItem("access_token");
localStorage.removeItem("username");
window.location.href = "/static/login.html";
window.location.href = "/login.html";
}
}
</script>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Login</title>
<title>Baby Monitor</title>
<style>
body {
font-family: Arial, sans-serif;
+368
View File
@@ -0,0 +1,368 @@
<!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>