feat: Implement initial setup functionality with admin elevation and system settings management

This commit is contained in:
Christian Krakau-Louis
2025-04-16 11:29:52 +02:00
parent 254853f08e
commit 61a0a0a6a6
5 changed files with 388 additions and 2 deletions
+20 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import inspect
from sqlalchemy.orm import Session
from passlib.context import CryptContext
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, SystemSettings
from .db import SessionLocal, engine
from .db_migrations import run_migrations
@@ -35,6 +35,25 @@ def init_db():
run_migrations(engine)
# Then proceed with seeding if needed
seed_db()
# Initialize system settings if needed
init_system_settings()
def init_system_settings():
"""Initialize the system settings table if it doesn't exist."""
db = SessionLocal()
try:
# Check if there's already a system settings record
settings = db.query(SystemSettings).first()
if not settings:
# Create initial system settings with setup_completed = False
settings = SystemSettings(setup_completed=False)
db.add(settings)
db.commit()
print("System settings initialized.")
except Exception as e:
print(f"Error initializing system settings: {e}")
finally:
db.close()
def seed_db():
"""Seed the database with test data."""
+2 -1
View File
@@ -15,7 +15,7 @@ import contextlib
from .db import engine, get_db
from . import models
from .templates_config import templates
from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience
from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience, setup
from .db_init import init_db
from .auth.middleware import SessionAuthBackend, on_auth_error
@@ -125,6 +125,7 @@ async def not_found_exception_handler(request: Request, exc):
)
# Routers
app.include_router(setup.router, tags=["Setup"]) # Setup router for initial admin setup
app.include_router(pages.router, tags=["Pages"]) # Pages router for index and static pages
app.include_router(auth.router, prefix="/auth", tags=["auth"]) # Include the auth router
app.include_router(qr.router, prefix="/qr", tags=["QR"])
+12
View File
@@ -66,6 +66,18 @@ class User(Base, BaseUser):
return f"<User {self.username}>"
# Add SystemSettings model for storing setup configuration
class SystemSettings(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
setup_completed = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
def __repr__(self):
return f"<SystemSettings setup_completed={self.setup_completed}>"
class OAuthAccount(Base):
__tablename__ = "oauth_accounts"
id = Column(Integer, primary_key=True, index=True)
+226
View File
@@ -0,0 +1,226 @@
{% extends "base.html" %}
{% block title %}LeagueLedger Setup{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="flex justify-center">
<div class="w-full max-w-3xl">
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<!-- Header -->
<div class="bg-irish-green text-white p-4">
<h3 class="text-xl font-bold flex items-center">
<i class="fas fa-tools mr-2"></i>LeagueLedger Setup
</h3>
</div>
<!-- Body -->
<div class="p-6">
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
<div class="flex items-center">
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
<p>Welcome to the LeagueLedger setup page. This page is only available once during initial setup.</p>
</div>
</div>
{% if setup_completed %}
<div class="bg-green-50 border-l-4 border-green-400 p-4 mb-6">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
<p><strong>Setup has been completed.</strong> The system has been configured with an administrator.</p>
</div>
</div>
{% if is_admin %}
<p class="mb-4">You already have administrator privileges. You can:</p>
<div class="flex justify-center">
<a href="/admin" class="bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-4 rounded flex items-center">
<i class="fas fa-cogs mr-2"></i>Go to Admin Dashboard
</a>
</div>
{% else %}
<p class="mb-4">This setup has already been completed by another user. Contact an administrator if you need admin access.</p>
{% endif %}
{% else %}
<p class="mb-4">Welcome, <strong>{{ user.username }}</strong>!</p>
<div class="my-6">
<h4 class="text-lg font-bold text-irish-green mb-2">System Setup</h4>
<p class="mb-2">
You're about to be promoted to administrator. As an administrator, you will be able to:
</p>
<ul class="list-disc pl-6 mb-4 space-y-1">
<li>Access all administrative functions</li>
<li>Manage users and teams</li>
<li>Generate and manage QR codes</li>
<li>Configure system settings</li>
</ul>
</div>
<div class="flex justify-center">
<button id="elevateBtn" class="bg-golden-ale hover:bg-opacity-90 text-black-stout font-bold py-3 px-6 rounded flex items-center">
<i class="fas fa-user-shield mr-2"></i>Make Me Administrator
</button>
</div>
{% endif %}
</div>
<!-- Footer -->
<div class="bg-gray-50 px-6 py-4 border-t">
<div class="flex justify-between items-center">
<a href="/" class="border border-gray-300 text-gray-600 hover:bg-gray-100 font-semibold py-2 px-4 rounded flex items-center">
<i class="fas fa-home mr-2"></i>Return to Home
</a>
{% if not setup_completed %}
<span class="text-gray-500 text-sm flex items-center">
<i class="fas fa-info-circle mr-1"></i>
This page will be disabled after setup
</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Success Modal -->
<div id="successModal" class="fixed z-10 inset-0 overflow-y-auto hidden" aria-labelledby="successModalLabel" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<!-- Modal panel -->
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-green-500 text-white px-4 py-3 sm:px-6">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-white" id="successModalLabel">
<i class="fas fa-check-circle mr-2"></i>Setup Complete
</h3>
<button type="button" class="modalClose text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="text-center mb-4">
<i class="fas fa-user-shield text-green-500 text-5xl"></i>
</div>
<p class="mb-2">Congratulations! You have been successfully promoted to administrator.</p>
<p>You can now access all administrative functions of LeagueLedger.</p>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<a href="/admin" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-irish-green text-base font-medium text-white hover:bg-opacity-90 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-cogs mr-2"></i>Go to Admin Dashboard
</a>
<button type="button" class="modalClose mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
<!-- Error Modal -->
<div id="errorModal" class="fixed z-10 inset-0 overflow-y-auto hidden" aria-labelledby="errorModalLabel" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<!-- Modal panel -->
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-red-500 text-white px-4 py-3 sm:px-6">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-white" id="errorModalLabel">
<i class="fas fa-exclamation-circle mr-2"></i>Error
</h3>
<button type="button" class="modalClose text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<p id="errorMessage" class="text-red-600">An error occurred while processing your request.</p>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" class="modalClose w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const elevateBtn = document.getElementById('elevateBtn');
const successModal = document.getElementById('successModal');
const errorModal = document.getElementById('errorModal');
const errorMessage = document.getElementById('errorMessage');
const closeButtons = document.querySelectorAll('.modalClose');
// Close modal function
function closeModal(modal) {
modal.classList.add('hidden');
}
// Show modal function
function showModal(modal) {
modal.classList.remove('hidden');
}
// Add event listeners to close buttons
closeButtons.forEach(button => {
button.addEventListener('click', function() {
closeModal(this.closest('.fixed'));
});
});
// Process admin elevation
if (elevateBtn) {
elevateBtn.addEventListener('click', function() {
// Disable button to prevent multiple submissions
elevateBtn.disabled = true;
elevateBtn.innerHTML = '<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Processing...';
// Send AJAX request
fetch('/setup/elevate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success modal
showModal(successModal);
} else {
// Show error modal
errorMessage.textContent = data.message || 'An error occurred.';
showModal(errorModal);
// Re-enable button
elevateBtn.disabled = false;
elevateBtn.innerHTML = '<i class="fas fa-user-shield mr-2"></i>Make Me Administrator';
}
})
.catch(error => {
console.error('Error:', error);
// Show error modal
errorMessage.textContent = 'Network error. Please try again.';
showModal(errorModal);
// Re-enable button
elevateBtn.disabled = false;
elevateBtn.innerHTML = '<i class="fas fa-user-shield mr-2"></i>Make Me Administrator';
});
});
}
});
</script>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from typing import Optional
from sqlalchemy.orm import Session
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
from datetime import datetime
import logging
# Set up logging
logger = logging.getLogger(__name__)
from ..db import get_db
from ..models import User, SystemSettings
from ..templates_config import templates
from ..security import verify_password
from ..auth.oauth import oauth_manager
# Create router
router = APIRouter(tags=["Setup"])
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(request: Request, db: Session = Depends(get_db)):
"""
Setup page that allows a logged-in user to elevate themselves to admin.
Only available once before being deactivated.
"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
logger.warning("Unauthorized access attempt to setup page")
# Add a query parameter for redirect back to setup after login
return RedirectResponse("/auth/login?next=/setup", status_code=HTTP_303_SEE_OTHER)
# Get the logged-in user
user = db.query(User).filter(User.id == user_id).first()
if not user:
logger.warning(f"User ID {user_id} found in session but not in database")
request.session.clear()
return RedirectResponse("/auth/login?next=/setup", status_code=HTTP_303_SEE_OTHER)
# Check if setup has already been completed
settings = db.query(SystemSettings).first()
setup_completed = settings and settings.setup_completed
# Check if the current user is already admin
is_admin = user.is_admin
return templates.TemplateResponse(
"admin/setup.html",
{
"request": request,
"user": user,
"setup_completed": setup_completed,
"is_admin": is_admin
}
)
@router.post("/setup/elevate", response_class=JSONResponse)
async def elevate_to_admin(request: Request, db: Session = Depends(get_db)):
"""
API endpoint to elevate current user to admin and mark setup as completed.
Returns JSON response for AJAX handling.
"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
logger.warning("Unauthorized API call to elevate_to_admin")
return JSONResponse(
status_code=HTTP_401_UNAUTHORIZED,
content={"success": False, "message": "Authentication required"}
)
# Check if setup has already been completed
settings = db.query(SystemSettings).first()
if settings and settings.setup_completed:
logger.warning("Setup already completed, but elevate_to_admin was called")
return JSONResponse(
content={
"success": False,
"message": "Setup has already been completed"
}
)
try:
# Get the logged-in user
user = db.query(User).filter(User.id == user_id).first()
if not user:
logger.warning(f"User ID {user_id} not found in database during elevate_to_admin")
return JSONResponse(
status_code=HTTP_401_UNAUTHORIZED,
content={"success": False, "message": "User not found"}
)
# Elevate user to admin
user.is_admin = True
user.last_login = datetime.utcnow()
# Mark setup as completed in system settings
if not settings:
settings = SystemSettings(setup_completed=True)
db.add(settings)
else:
settings.setup_completed = True
# Save the changes with explicit commit
db.commit()
logger.info(f"User {user.username} (ID: {user.id}) elevated to admin in setup")
# Update the session to reflect admin status
request.session["is_admin"] = True
return JSONResponse(
content={
"success": True,
"message": "You have been successfully promoted to administrator"
}
)
except Exception as e:
db.rollback()
logger.error(f"Error during admin elevation: {str(e)}")
return JSONResponse(
content={
"success": False,
"message": f"An error occurred: {str(e)}"
}
)