feat: Implement initial setup functionality with admin elevation and system settings management
This commit is contained in:
+20
-1
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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)}"
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user