ruff and prettier fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m6s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 15s

This commit is contained in:
Brian Bjarke Jensen
2025-11-07 23:10:59 +01:00
parent 469fd722de
commit 2b956213aa
10 changed files with 644 additions and 655 deletions
+1
View File
@@ -6,6 +6,7 @@ from datetime import datetime
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase Base = DeclarativeBase
else: else:
from baby_monitor.repositories.dependencies.get_database import Base from baby_monitor.repositories.dependencies.get_database import Base
+3 -6
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Mapped, mapped_column
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase Base = DeclarativeBase
else: else:
from baby_monitor.repositories.dependencies.get_database import Base from baby_monitor.repositories.dependencies.get_database import Base
@@ -21,9 +22,7 @@ class Invitation(Base):
__tablename__ = "invitations" __tablename__ = "invitations"
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
Integer, primary_key=True, autoincrement=True
)
token: Mapped[str] = mapped_column(String, unique=True, nullable=False) token: Mapped[str] = mapped_column(String, unique=True, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False DateTime(timezone=True), nullable=False
@@ -32,9 +31,7 @@ class Invitation(Base):
DateTime(timezone=True), nullable=False DateTime(timezone=True), nullable=False
) )
created_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False) created_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False)
is_consumed: Mapped[bool] = mapped_column( is_consumed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
Boolean, default=False, nullable=False
)
consumed_at: Mapped[datetime | None] = mapped_column( consumed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
@@ -36,7 +36,7 @@ def init_db() -> None:
# This must be done before create_all() is called # This must be done before create_all() is called
from baby_monitor.models.db.user import User # noqa: F401 from baby_monitor.models.db.user import User # noqa: F401
from baby_monitor.models.invitation import Invitation # 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)
@@ -34,9 +34,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
# Convert timezone-aware datetimes to naive UTC for SQLite # Convert timezone-aware datetimes to naive UTC for SQLite
created_at_utc = datetime.now(UTC).replace(tzinfo=None) created_at_utc = datetime.now(UTC).replace(tzinfo=None)
expires_at_utc = ( expires_at_utc = (
expires_at.replace(tzinfo=None) expires_at.replace(tzinfo=None) if expires_at.tzinfo else expires_at
if expires_at.tzinfo
else expires_at
) )
invitation = Invitation( invitation = Invitation(
@@ -60,9 +58,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
Returns: Returns:
True if valid and not consumed, False otherwise True if valid and not consumed, False otherwise
""" """
invitation = ( invitation = self.db.query(Invitation).filter(Invitation.token == token).first()
self.db.query(Invitation).filter(Invitation.token == token).first()
)
if not invitation: if not invitation:
return False return False
@@ -88,9 +84,7 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
Returns: Returns:
True if successfully consumed, False if invalid or already used True if successfully consumed, False if invalid or already used
""" """
invitation = ( invitation = self.db.query(Invitation).filter(Invitation.token == token).first()
self.db.query(Invitation).filter(Invitation.token == token).first()
)
if not invitation: if not invitation:
return False return False
@@ -114,7 +108,5 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
def cleanup_expired(self) -> None: def cleanup_expired(self) -> None:
"""Remove expired invitation tokens from storage.""" """Remove expired invitation tokens from storage."""
now_utc = datetime.now(UTC).replace(tzinfo=None) now_utc = datetime.now(UTC).replace(tzinfo=None)
self.db.query(Invitation).filter( self.db.query(Invitation).filter(Invitation.expires_at < now_utc).delete()
Invitation.expires_at < now_utc
).delete()
self.db.commit() self.db.commit()
+7 -7
View File
@@ -34,31 +34,31 @@ async def generate_invitation_link(
], ],
) -> InvitationResponse: ) -> InvitationResponse:
"""Generate a new invitation link for user registration. """Generate a new invitation link for user registration.
The invitation token is valid for 24 hours and can be used once. The invitation token is valid for 24 hours and can be used once.
Requires admin role. Requires admin role.
Args: Args:
user_id: Admin user ID (from verify_admin) user_id: Admin user ID (from verify_admin)
invitation_repository: Invitation storage backend invitation_repository: Invitation storage backend
Returns: Returns:
InvitationResponse with token and expiration details InvitationResponse with token and expiration details
""" """
# Generate secure random token # Generate secure random token
invitation_token = secrets.token_urlsafe(32) invitation_token = secrets.token_urlsafe(32)
# Calculate expiration (24 hours from now) # Calculate expiration (24 hours from now)
expires_at = datetime.now(UTC) + timedelta(hours=24) expires_at = datetime.now(UTC) + timedelta(hours=24)
# Store invitation token in database # Store invitation token in database
invitation_repository.create_invitation( invitation_repository.create_invitation(
token=invitation_token, token=invitation_token,
created_by_user_id=user_id, created_by_user_id=user_id,
expires_at=expires_at, expires_at=expires_at,
) )
return InvitationResponse( return InvitationResponse(
token=invitation_token, token=invitation_token,
expires_at=expires_at.isoformat(), expires_at=expires_at.isoformat(),
+22 -40
View File
@@ -52,27 +52,23 @@ def verify_token(
def verify_admin( def verify_admin(
user_id: Annotated[int, Depends(verify_token)], user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[ user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
UserRepositoryInterface, Depends(get_user_repository)
],
) -> int: ) -> int:
"""Verify the user is an admin and return user_id.""" """Verify the user is an admin and return user_id."""
# First try to get user from database # First try to get user from database
user = user_repo.get_by_id(user_id) user = user_repo.get_by_id(user_id)
if user: if user:
# Database user - check is_admin field # Database user - check is_admin field
if not user.get("is_admin", False): if not user.get("is_admin", False):
raise HTTPException( raise HTTPException(status_code=403, detail="Admin access required")
status_code=403, detail="Admin access required"
)
return user_id return user_id
# If not in database but has valid token with user_id=1, # If not in database but has valid token with user_id=1,
# it's the environment-based admin (only assigned during env admin login) # it's the environment-based admin (only assigned during env admin login)
if user_id == 1: if user_id == 1:
return user_id return user_id
# User not found and not environment admin # User not found and not environment admin
raise HTTPException(status_code=401, detail="User not found") raise HTTPException(status_code=401, detail="User not found")
@@ -80,12 +76,8 @@ def verify_admin(
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login( def login(
credentials: LoginRequest, credentials: LoginRequest,
token_repo: Annotated[ token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)],
TokenRepositoryInterface, Depends(get_token_repository) user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
],
user_repo: Annotated[
UserRepositoryInterface, Depends(get_user_repository)
],
creds_repo: Annotated[ creds_repo: Annotated[
CredentialsRepositoryInterface, Depends(get_credentials_repository) CredentialsRepositoryInterface, Depends(get_credentials_repository)
], ],
@@ -112,11 +104,9 @@ def login(
access_token=access_token, access_token=access_token,
is_admin=user.get("is_admin", False), is_admin=user.get("is_admin", False),
) )
# Fallback to admin credentials from environment # Fallback to admin credentials from environment
if creds_repo.verify_admin_credentials( if creds_repo.verify_admin_credentials(credentials.username, credentials.password):
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 admin) # Store token with user_id (hardcoded 1 for admin)
@@ -128,10 +118,8 @@ def login(
access_token=access_token, access_token=access_token,
is_admin=True, is_admin=True,
) )
raise HTTPException( raise HTTPException(status_code=401, detail="Invalid username or password")
status_code=401, detail="Invalid username or password"
)
@router.post("/logout") @router.post("/logout")
@@ -162,19 +150,15 @@ def verify_invitation(
@router.post("/register", response_model=RegisterResponse) @router.post("/register", response_model=RegisterResponse)
def register( def register(
request: RegisterRequest, request: RegisterRequest,
user_repo: Annotated[ user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
UserRepositoryInterface, Depends(get_user_repository)
],
invitation_repo: Annotated[ invitation_repo: Annotated[
InvitationRepositoryInterface, Depends(get_invitation_repository) InvitationRepositoryInterface, Depends(get_invitation_repository)
], ],
token_repo: Annotated[ token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)],
TokenRepositoryInterface, Depends(get_token_repository)
],
) -> RegisterResponse: ) -> RegisterResponse:
""" """
Register a new user with an invitation token. Register a new user with an invitation token.
Verifies the invitation, creates the user, consumes the invitation, Verifies the invitation, creates the user, consumes the invitation,
and returns an authentication token. and returns an authentication token.
""" """
@@ -184,7 +168,7 @@ def register(
status_code=400, status_code=400,
detail="Invalid or expired invitation token", detail="Invalid or expired invitation token",
) )
# Check if username already exists # Check if username already exists
existing_user = user_repo.get_by_username(request.username) existing_user = user_repo.get_by_username(request.username)
if existing_user: if existing_user:
@@ -192,21 +176,21 @@ def register(
status_code=400, status_code=400,
detail="Username already exists", detail="Username already exists",
) )
# Create the new user # Create the new user
# TODO: Hash password before storing (currently plain text) # TODO: Hash password before storing (currently plain text)
user = user_repo.create( user = user_repo.create(
username=request.username, username=request.username,
hashed_password=request.password, hashed_password=request.password,
) )
# Consume the invitation token # Consume the invitation token
invitation_repo.consume_invitation(request.invitation_token) invitation_repo.consume_invitation(request.invitation_token)
# Generate authentication token # Generate authentication token
access_token = secrets.token_urlsafe(32) access_token = secrets.token_urlsafe(32)
token_repo.store(access_token, user_id=user["id"], ttl=3600) token_repo.store(access_token, user_id=user["id"], ttl=3600)
return RegisterResponse( return RegisterResponse(
message="Registration successful", message="Registration successful",
username=user["username"], username=user["username"],
@@ -218,16 +202,14 @@ def register(
@router.get("/me") @router.get("/me")
def get_current_user( def get_current_user(
user_id: Annotated[int, Depends(verify_token)], user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[ user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
UserRepositoryInterface, Depends(get_user_repository)
],
creds_repo: Annotated[ creds_repo: Annotated[
CredentialsRepositoryInterface, Depends(get_credentials_repository) CredentialsRepositoryInterface, Depends(get_credentials_repository)
], ],
) -> dict: ) -> dict:
"""Get current user info including admin status.""" """Get current user info including admin status."""
user = user_repo.get_by_id(user_id) user = user_repo.get_by_id(user_id)
# If user not found in DB, might be env-based admin # If user not found in DB, might be env-based admin
if not user: if not user:
# Return admin info from environment # Return admin info from environment
@@ -236,7 +218,7 @@ def get_current_user(
"username": creds_repo.get_admin_username(), "username": creds_repo.get_admin_username(),
"is_admin": True, # Env-based admin is always admin "is_admin": True, # Env-based admin is always admin
} }
return { return {
"id": user["id"], "id": user["id"],
"username": user["username"], "username": user["username"],
+267 -263
View File
@@ -1,315 +1,319 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<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 - Admin</title> <title>Baby Monitor - Admin</title>
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family:
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
min-height: 100vh; Cantarell, sans-serif;
display: flex; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
justify-content: center; min-height: 100vh;
align-items: center; display: flex;
padding: 20px; justify-content: center;
} align-items: center;
padding: 20px;
}
.container { .container {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px; padding: 40px;
max-width: 600px; max-width: 600px;
width: 100%; width: 100%;
} }
h1 { h1 {
color: #333; color: #333;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 28px; font-size: 28px;
} }
.subtitle { .subtitle {
color: #666; color: #666;
margin-bottom: 30px; margin-bottom: 30px;
font-size: 14px; font-size: 14px;
} }
.section { .section {
margin-bottom: 30px; margin-bottom: 30px;
} }
.section h2 { .section h2 {
color: #444; color: #444;
font-size: 18px; font-size: 18px;
margin-bottom: 15px; margin-bottom: 15px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.section h2::before { .section h2::before {
content: '🔗'; content: "🔗";
margin-right: 8px; margin-right: 8px;
} }
.button { .button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
border: none; border: none;
padding: 12px 24px; padding: 12px 24px;
border-radius: 6px; border-radius: 6px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition:
width: 100%; transform 0.2s,
} box-shadow 0.2s;
width: 100%;
}
.button:hover { .button:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
} }
.button:active { .button:active {
transform: translateY(0); transform: translateY(0);
} }
.button:disabled { .button:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.link-container { .link-container {
display: none; display: none;
margin-top: 20px; margin-top: 20px;
padding: 15px; padding: 15px;
background: #f8f9fa; background: #f8f9fa;
border-radius: 8px; border-radius: 8px;
border: 2px solid #667eea; border: 2px solid #667eea;
} }
.link-container.show { .link-container.show {
display: block; display: block;
} }
.link-label { .link-label {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
} }
.link-display { .link-display {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
} }
.link-text { .link-text {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
background: white; background: white;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-family: 'Courier New', monospace; font-family: "Courier New", monospace;
font-size: 13px; font-size: 13px;
word-break: break-all; word-break: break-all;
color: #333; color: #333;
} }
.copy-button { .copy-button {
padding: 10px 16px; padding: 10px 16px;
background: #28a745; background: #28a745;
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.copy-button:hover { .copy-button:hover {
background: #218838; background: #218838;
} }
.copy-button.copied { .copy-button.copied {
background: #155724; background: #155724;
} }
.info-box { .info-box {
background: #e7f3ff; background: #e7f3ff;
border-left: 4px solid #2196F3; border-left: 4px solid #2196f3;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-top: 15px; margin-top: 15px;
font-size: 14px; font-size: 14px;
color: #555; color: #555;
} }
.error { .error {
background: #ffebee; background: #ffebee;
border-left: 4px solid #f44336; border-left: 4px solid #f44336;
color: #c62828; color: #c62828;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-top: 15px; margin-top: 15px;
display: none; display: none;
} }
.error.show { .error.show {
display: block; display: block;
} }
.logout-button { .logout-button {
background: #dc3545; background: #dc3545;
padding: 8px 16px; padding: 8px 16px;
font-size: 14px; font-size: 14px;
margin-top: 20px; margin-top: 20px;
} }
.logout-button:hover { .logout-button:hover {
background: #c82333; background: #c82333;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4); box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>👨‍💼 Admin Dashboard</h1> <h1>👨‍💼 Admin Dashboard</h1>
<p class="subtitle">Manage user invitations and settings</p> <p class="subtitle">Manage user invitations and settings</p>
<div class="section"> <div class="section">
<h2>Generate Invitation Link</h2> <h2>Generate Invitation Link</h2>
<p style="color: #666; margin-bottom: 15px; font-size: 14px;"> <p style="color: #666; margin-bottom: 15px; font-size: 14px">
Create a secure one-time link to invite a new user to register. Create a secure one-time link to invite a new user to register.
</p> </p>
<button class="button" id="generateBtn" onclick="generateLink()"> <button class="button" id="generateBtn" onclick="generateLink()">
Generate New Invitation Link 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> </button>
</div>
<div id="linkContainer" class="link-container"> <div class="info-box">
<div class="link-label">Invitation Link</div> ️ This link expires in 24 hours and can only be used once. Share it
<div class="link-display"> securely with the intended user.
<div class="link-text" id="linkText"></div> </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> </div>
<button class="button logout-button" onclick="logout()"> <div id="error" class="error"></div>
Logout </div>
</button>
<button class="button logout-button" onclick="logout()">Logout</button>
</div> </div>
<script> <script>
// Check if user is authenticated and is admin // Check if user is authenticated and is admin
const token = localStorage.getItem('access_token'); const token = localStorage.getItem("access_token");
if (!token) { if (!token) {
window.location.href = '/login.html'; window.location.href = "/login.html";
} }
// Verify user is admin // Verify user is admin
fetch('/api/me', { fetch("/api/me", {
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
} },
})
.then((response) => response.json())
.then((user) => {
if (!user.is_admin) {
alert("Access denied. Admin privileges required.");
window.location.href = "/";
}
}) })
.then(response => response.json()) .catch((error) => {
.then(user => { console.error("Error verifying admin status:", error);
if (!user.is_admin) { window.location.href = "/login.html";
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() { async function generateLink() {
const btn = document.getElementById('generateBtn'); const btn = document.getElementById("generateBtn");
const linkContainer = document.getElementById('linkContainer'); const linkContainer = document.getElementById("linkContainer");
const linkText = document.getElementById('linkText'); const linkText = document.getElementById("linkText");
const errorDiv = document.getElementById('error'); const errorDiv = document.getElementById("error");
// Disable button and show loading state // Disable button and show loading state
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Generating...'; btn.textContent = "Generating...";
errorDiv.classList.remove('show'); errorDiv.classList.remove("show");
try { try {
const response = await fetch('/api/admin/generate-invitation', { const response = await fetch("/api/admin/generate-invitation", {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to generate link: ${response.statusText}`); throw new Error(`Failed to generate link: ${response.statusText}`);
} }
const data = await response.json(); 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) { // Build the full URL
errorDiv.textContent = `Error: ${error.message}`; const baseUrl = window.location.origin;
errorDiv.classList.add('show'); const inviteUrl = `${baseUrl}/register.html?token=${data.token}`;
} finally {
btn.disabled = false; // Display the link
btn.textContent = 'Generate New Invitation 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() { function copyLink() {
const linkText = document.getElementById('linkText').textContent; const linkText = document.getElementById("linkText").textContent;
const copyBtn = document.getElementById('copyBtn'); const copyBtn = document.getElementById("copyBtn");
navigator.clipboard.writeText(linkText).then(() => { navigator.clipboard
// Show success state .writeText(linkText)
copyBtn.textContent = '✓ Copied!'; .then(() => {
copyBtn.classList.add('copied'); // Show success state
copyBtn.textContent = "✓ Copied!";
copyBtn.classList.add("copied");
// Reset after 2 seconds // Reset after 2 seconds
setTimeout(() => { setTimeout(() => {
copyBtn.textContent = 'Copy'; copyBtn.textContent = "Copy";
copyBtn.classList.remove('copied'); copyBtn.classList.remove("copied");
}, 2000); }, 2000);
}).catch(err => { })
alert('Failed to copy link: ' + err); .catch((err) => {
}); alert("Failed to copy link: " + err);
} });
}
function logout() { function logout() {
localStorage.removeItem('access_token'); localStorage.removeItem("access_token");
window.location.href = '/login.html'; window.location.href = "/login.html";
} }
</script> </script>
</body> </body>
</html> </html>
+7 -9
View File
@@ -125,13 +125,11 @@
<div></div> <div></div>
<div></div> <div></div>
</div> </div>
<div class="menu-backdrop" id="menuBackdrop"></div> <div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay"> <div class="menu-overlay" id="menuOverlay">
<div class="menu-item logout" id="menuLogout"> <div class="menu-item logout" id="menuLogout">🚪 Logout</div>
🚪 Logout
</div>
</div> </div>
<div class="container"> <div class="container">
@@ -148,20 +146,20 @@
const burgerMenu = document.getElementById("burgerMenu"); const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay"); const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop"); const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() { function toggleMenu() {
menuOverlay.classList.toggle("open"); menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open"); menuBackdrop.classList.toggle("open");
} }
function closeMenu() { function closeMenu() {
menuOverlay.classList.remove("open"); menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open"); menuBackdrop.classList.remove("open");
} }
burgerMenu.addEventListener("click", toggleMenu); burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu); menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuLogout").addEventListener("click", () => { document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu(); closeMenu();
logout(); logout();
+1 -1
View File
@@ -112,7 +112,7 @@
// 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 based on is_admin from login response // Redirect based on is_admin from login response
setTimeout(() => { setTimeout(() => {
if (data.is_admin === true) { if (data.is_admin === true) {
+331 -316
View File
@@ -1,368 +1,383 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<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 - Register</title> <title>Baby Monitor - Register</title>
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family:
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
min-height: 100vh; Cantarell, sans-serif;
display: flex; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
justify-content: center; min-height: 100vh;
align-items: center; display: flex;
padding: 20px; justify-content: center;
} align-items: center;
padding: 20px;
}
.container { .container {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px; padding: 40px;
max-width: 450px; max-width: 450px;
width: 100%; width: 100%;
} }
h1 { h1 {
color: #333; color: #333;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 28px; font-size: 28px;
text-align: center; text-align: center;
} }
.subtitle { .subtitle {
color: #666; color: #666;
margin-bottom: 30px; margin-bottom: 30px;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
} }
.form-group { .form-group {
margin-bottom: 20px; margin-bottom: 20px;
} }
label { label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
color: #444; color: #444;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
} }
input { input {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
border: 2px solid #e0e0e0; border: 2px solid #e0e0e0;
border-radius: 6px; border-radius: 6px;
font-size: 16px; font-size: 16px;
transition: border-color 0.3s; transition: border-color 0.3s;
} }
input:focus { input:focus {
outline: none; outline: none;
border-color: #667eea; border-color: #667eea;
} }
.button { .button {
width: 100%; width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
border: none; border: none;
padding: 14px; padding: 14px;
border-radius: 6px; border-radius: 6px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition:
margin-top: 10px; transform 0.2s,
} box-shadow 0.2s;
margin-top: 10px;
}
.button:hover { .button:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
} }
.button:active { .button:active {
transform: translateY(0); transform: translateY(0);
} }
.button:disabled { .button:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.error { .error {
background: #ffebee; background: #ffebee;
border-left: 4px solid #f44336; border-left: 4px solid #f44336;
color: #c62828; color: #c62828;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-bottom: 20px; margin-bottom: 20px;
display: none; display: none;
font-size: 14px; font-size: 14px;
} }
.error.show { .error.show {
display: block; display: block;
} }
.success { .success {
background: #e8f5e9; background: #e8f5e9;
border-left: 4px solid #4caf50; border-left: 4px solid #4caf50;
color: #2e7d32; color: #2e7d32;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-bottom: 20px; margin-bottom: 20px;
display: none; display: none;
font-size: 14px; font-size: 14px;
} }
.success.show { .success.show {
display: block; display: block;
} }
.loading-container { .loading-container {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
} }
.spinner { .spinner {
border: 3px solid #f3f3f3; border: 3px solid #f3f3f3;
border-top: 3px solid #667eea; border-top: 3px solid #667eea;
border-radius: 50%; border-radius: 50%;
width: 40px; width: 40px;
height: 40px; height: 40px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin: 0 auto 20px; margin: 0 auto 20px;
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
} }
100% {
transform: rotate(360deg);
}
}
.info-box { .info-box {
background: #e7f3ff; background: #e7f3ff;
border-left: 4px solid #2196F3; border-left: 4px solid #2196f3;
padding: 12px; padding: 12px;
border-radius: 4px; border-radius: 4px;
margin-bottom: 20px; margin-bottom: 20px;
font-size: 14px; font-size: 14px;
color: #555; color: #555;
} }
.hidden { .hidden {
display: none; display: none;
} }
.password-requirements { .password-requirements {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
margin-top: 5px; margin-top: 5px;
} }
.password-requirements ul { .password-requirements ul {
margin: 5px 0 0 20px; margin: 5px 0 0 20px;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div id="loadingContainer" class="loading-container"> <div id="loadingContainer" class="loading-container">
<div class="spinner"></div> <div class="spinner"></div>
<p style="color: #666;">Validating invitation...</p> <p style="color: #666">Validating invitation...</p>
</div> </div>
<div id="registerContainer" class="hidden"> <div id="registerContainer" class="hidden">
<h1>🎉 Create Your Account</h1> <h1>🎉 Create Your Account</h1>
<p class="subtitle">Complete the registration to get started</p> <p class="subtitle">Complete the registration to get started</p>
<div id="error" class="error"></div> <div id="error" class="error"></div>
<div id="success" class="success"></div> <div id="success" class="success"></div>
<form id="registerForm" onsubmit="handleRegister(event)"> <form id="registerForm" onsubmit="handleRegister(event)">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input <input
type="text" type="text"
id="username" id="username"
name="username" name="username"
required required
minlength="3" minlength="3"
autocomplete="username" autocomplete="username"
placeholder="Choose a username" placeholder="Choose a username"
> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
<input <input
type="password" type="password"
id="password" id="password"
name="password" name="password"
required required
minlength="8" minlength="8"
autocomplete="new-password" autocomplete="new-password"
placeholder="Choose a strong password" placeholder="Choose a strong password"
> />
<div class="password-requirements"> <div class="password-requirements">
<ul> <ul>
<li>At least 8 characters long</li> <li>At least 8 characters long</li>
<li>Mix of letters, numbers recommended</li> <li>Mix of letters, numbers recommended</li>
</ul> </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> </div>
<p style="text-align: center; color: #666; margin-top: 20px;"> </div>
Please contact an administrator for a new invitation link.
</p> <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> </div>
<p style="text-align: center; color: #666; margin-top: 20px">
Please contact an administrator for a new invitation link.
</p>
</div>
</div> </div>
<script> <script>
let invitationToken = null; let invitationToken = null;
// Extract token from URL query parameters // Extract token from URL query parameters
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
invitationToken = urlParams.get('token'); invitationToken = urlParams.get("token");
// Validate token on page load // Validate token on page load
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener("DOMContentLoaded", async () => {
if (!invitationToken) { if (!invitationToken) {
showInvalidToken(); showInvalidToken();
return; 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() { try {
document.getElementById('loadingContainer').classList.add('hidden'); // Verify the invitation token
document.getElementById('invalidTokenContainer').classList.remove('hidden'); 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;
} }
async function handleRegister(event) { // Disable submit button
event.preventDefault(); submitBtn.disabled = true;
submitBtn.textContent = "Creating Account...";
const username = document.getElementById('username').value; try {
const password = document.getElementById('password').value; const response = await fetch("/api/register", {
const confirmPassword = document.getElementById('confirmPassword').value; method: "POST",
const errorDiv = document.getElementById('error'); headers: {
const successDiv = document.getElementById('success'); "Content-Type": "application/json",
const submitBtn = document.getElementById('submitBtn'); },
body: JSON.stringify({
username: username,
password: password,
invitation_token: invitationToken,
}),
});
// Clear previous messages const data = await response.json();
errorDiv.classList.remove('show');
successDiv.classList.remove('show');
// Validate passwords match if (response.ok) {
if (password !== confirmPassword) { // Store the access token and username
errorDiv.textContent = 'Passwords do not match!'; localStorage.setItem("access_token", data.access_token);
errorDiv.classList.add('show'); localStorage.setItem("username", data.username);
return;
}
// Disable submit button // Show success message
submitBtn.disabled = true; successDiv.textContent =
submitBtn.textContent = 'Creating Account...'; "Account created successfully! Redirecting...";
successDiv.classList.add("show");
try { // Redirect to home page after a brief delay
const response = await fetch('/api/register', { setTimeout(() => {
method: 'POST', window.location.href = "/";
headers: { }, 1500);
'Content-Type': 'application/json', } else {
}, errorDiv.textContent =
body: JSON.stringify({ data.detail || "Registration failed. Please try again.";
username: username, errorDiv.classList.add("show");
password: password, submitBtn.disabled = false;
invitation_token: invitationToken, submitBtn.textContent = "Create Account";
}), }
}); } catch (error) {
console.error("Registration error:", error);
const data = await response.json(); errorDiv.textContent = "An error occurred. Please try again.";
errorDiv.classList.add("show");
if (response.ok) { submitBtn.disabled = false;
// Store the access token and username submitBtn.textContent = "Create Account";
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> </script>
</body> </body>
</html> </html>