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
) )
@@ -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()
+9 -27
View File
@@ -52,9 +52,7 @@ 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
@@ -63,9 +61,7 @@ def verify_admin(
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,
@@ -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)
], ],
@@ -114,9 +106,7 @@ def login(
) )
# 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)
@@ -129,9 +119,7 @@ def login(
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,15 +150,11 @@ 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.
@@ -218,9 +202,7 @@ 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)
], ],
+266 -262
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 // Build the full URL
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
const inviteUrl = `${baseUrl}/register.html?token=${data.token}`; const inviteUrl = `${baseUrl}/register.html?token=${data.token}`;
// Display the link // Display the link
linkText.textContent = inviteUrl; linkText.textContent = inviteUrl;
linkContainer.classList.add('show'); linkContainer.classList.add("show");
} catch (error) {
} catch (error) { errorDiv.textContent = `Error: ${error.message}`;
errorDiv.textContent = `Error: ${error.message}`; errorDiv.classList.add("show");
errorDiv.classList.add('show'); } finally {
} finally { btn.disabled = false;
btn.disabled = false; btn.textContent = "Generate New Invitation Link";
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>
+1 -3
View File
@@ -129,9 +129,7 @@
<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">
+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>