Added custom error pages, user password change, initial setup workflow with default admin user.

This commit is contained in:
2025-09-07 11:40:45 -04:00
parent ee1715ff4f
commit c94c0554df
9 changed files with 1444 additions and 89 deletions

View File

@@ -3,11 +3,11 @@
let let
version = "1.8.2"; version = "1.8.2";
system = "x86_64-linux"; system = "x86_64-linux";
devenv_root = "/home/th3r00t/Projects/dosfrontend"; devenv_root = "/home/th3r00t/Projects/DosVault";
devenv_dotfile = "/home/th3r00t/Projects/dosfrontend/.devenv"; devenv_dotfile = "/home/th3r00t/Projects/DosVault/.devenv";
devenv_dotfile_path = ./.devenv; devenv_dotfile_path = ./.devenv;
devenv_tmpdir = "/run/user/1000"; devenv_tmpdir = "/run/user/1000";
devenv_runtime = "/run/user/1000/devenv-9a894c0"; devenv_runtime = "/run/user/1000/devenv-3442663";
devenv_istesting = false; devenv_istesting = false;
devenv_direnvrc_latest_version = 1; devenv_direnvrc_latest_version = 1;
container_name = null; container_name = null;
@@ -26,11 +26,11 @@ container_name = null;
let let
version = "1.8.2"; version = "1.8.2";
system = "x86_64-linux"; system = "x86_64-linux";
devenv_root = "/home/th3r00t/Projects/dosfrontend"; devenv_root = "/home/th3r00t/Projects/DosVault";
devenv_dotfile = "/home/th3r00t/Projects/dosfrontend/.devenv"; devenv_dotfile = "/home/th3r00t/Projects/DosVault/.devenv";
devenv_dotfile_path = ./.devenv; devenv_dotfile_path = ./.devenv;
devenv_tmpdir = "/run/user/1000"; devenv_tmpdir = "/run/user/1000";
devenv_runtime = "/run/user/1000/devenv-9a894c0"; devenv_runtime = "/run/user/1000/devenv-3442663";
devenv_istesting = false; devenv_istesting = false;
devenv_direnvrc_latest_version = 1; devenv_direnvrc_latest_version = 1;
container_name = null; container_name = null;

1
WARP.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -13,8 +13,15 @@ from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSON
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.exception_handlers import http_exception_handler
from fastapi.exceptions import HTTPException as StarletteHTTPException
from starlette.exceptions import HTTPException as StarletteBaseHTTPException
from sqlalchemy import create_engine, select, func from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.exc import OperationalError
from alembic.config import Config as AlembicConfig
from alembic import command
import subprocess
try: try:
# Try relative imports first (when run as module) # Try relative imports first (when run as module)
@@ -54,13 +61,219 @@ app.add_middleware(
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
# Database tables are now managed by migrations # Database initialization functions using Alembic
# Base.metadata.create_all(bind=engine) def check_database_exists():
"""Check if the database has been initialized with basic tables"""
try:
with SessionLocal() as db:
# Try to query a basic table to see if database is initialized
db.scalar(select(func.count(User_table.id)))
return True
except OperationalError:
return False
except Exception:
return False
def run_alembic_migrations():
"""Run Alembic migrations to initialize/update database schema"""
try:
# Use the migrate command via subprocess to ensure proper environment
result = subprocess.run(
["python", "-m", "devenv", "db-upgrade"],
cwd=".",
capture_output=True,
text=True
)
if result.returncode == 0:
logging.info("Database migrations completed successfully")
return True
else:
logging.error(f"Migration failed: {result.stderr}")
# Fall back to direct Alembic command
try:
# Create alembic config
alembic_cfg = AlembicConfig("alembic.ini")
command.upgrade(alembic_cfg, "head")
logging.info("Database migrations completed via direct Alembic")
return True
except Exception as e:
logging.error(f"Direct Alembic migration also failed: {e}")
return False
except Exception as e:
logging.error(f"Failed to run migrations: {e}")
return False
def ensure_super_user():
"""Ensure at least one super user exists, create default if none"""
try:
with SessionLocal() as db:
# Check if any super user exists
super_user = db.scalar(select(User_table).where(User_table.role == UserRole.SUPER.value))
if super_user:
logging.info(f"Super user already exists: {super_user.username}")
return True
# Create default super user
logging.info("No super user found, creating default admin user...")
try:
default_admin = AuthManager.create_user(
session=db,
username="admin",
email="admin@dosvault.local",
password="admin123",
role=UserRole.SUPER.value
)
logging.warning("Default super user created: admin / admin123")
logging.warning("IMPORTANT: Please change the default admin password immediately!")
return True
except Exception as e:
logging.error(f"Failed to create default super user: {e}")
return False
except Exception as e:
logging.error(f"Failed to ensure super user: {e}")
return False
def initialize_database_and_users():
"""Initialize database and ensure super user exists"""
success = True
# Check if database exists
if not check_database_exists():
logging.info("Database not initialized, running migrations...")
if not run_alembic_migrations():
logging.error("Failed to initialize database")
success = False
# Ensure super user exists
if success and not ensure_super_user():
logging.error("Failed to ensure super user exists")
success = False
return success
# Global flags to track initialization status
db_initialization_attempted = False
db_initialization_successful = False
@app.on_event("startup")
async def startup_event():
"""Handle application startup - initialize database in background"""
global db_initialization_attempted, db_initialization_successful
logging.info("Starting database initialization...")
db_initialization_attempted = True
try:
# Run initialization in background
success = await asyncio.get_event_loop().run_in_executor(
None, initialize_database_and_users
)
db_initialization_successful = success
if success:
logging.info("Database initialization completed successfully")
else:
logging.error("Database initialization failed")
except Exception as e:
logging.error(f"Error during database initialization: {e}")
db_initialization_successful = False
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
templates.env.globals['max'] = max templates.env.globals['max'] = max
templates.env.globals['min'] = min templates.env.globals['min'] = min
# Error handlers
@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
"""Handle HTTP exceptions with custom error pages"""
try:
# Get current user for error page context
current_user = None
try:
if "auth_token" in request.cookies:
token = request.cookies["auth_token"]
with SessionLocal() as db:
username = AuthManager.verify_token(token)
if username:
current_user = AuthManager.get_user_by_username(db, username)
if current_user and not current_user.is_active:
current_user = None
except Exception:
pass
# Handle authentication errors - redirect to login page
if exc.status_code == 401:
# For AJAX/API requests, return JSON error
if request.headers.get("accept", "").startswith("application/json") or "/api/" in str(request.url):
return JSONResponse(
status_code=401,
content={"detail": "Authentication required"}
)
# For page requests, redirect to login with error message
redirect_url = f"/login?error={exc.detail}&redirect={request.url.path}"
return RedirectResponse(url=redirect_url, status_code=303)
# Handle 404 and other errors with custom error page
error_details = {
"request": request,
"current_user": current_user,
"error_code": exc.status_code,
"error_title": {
404: "Page Not Found",
403: "Access Forbidden",
500: "Internal Server Error",
}.get(exc.status_code, "Error"),
"error_description": {
404: "The page you're looking for doesn't exist or has been moved.",
403: "You don't have permission to access this resource.",
500: "An internal server error occurred. Please try again later.",
}.get(exc.status_code, str(exc.detail)),
"error_details": str(exc.detail) if exc.status_code >= 500 else None
}
return templates.TemplateResponse(
"error.html",
error_details,
status_code=exc.status_code
)
except Exception as e:
# Fallback to default error handling if our custom handler fails
logging.error(f"Error in custom exception handler: {e}")
return await http_exception_handler(request, exc)
@app.get("/login", response_class=HTMLResponse)
async def login_page(
request: Request,
error: Optional[str] = Query(None),
redirect: Optional[str] = Query(None)
):
"""Display login page"""
# If user is already logged in, redirect to home or requested page
try:
if "auth_token" in request.cookies:
token = request.cookies["auth_token"]
with SessionLocal() as db:
username = AuthManager.verify_token(token)
if username:
current_user = AuthManager.get_user_by_username(db, username)
if current_user and current_user.is_active:
return RedirectResponse(url=redirect or "/", status_code=303)
except Exception:
pass
return templates.TemplateResponse("login.html", {
"request": request,
"error": error,
"redirect": redirect or "/"
})
# Removed proxy image URL function # Removed proxy image URL function
@@ -125,70 +338,137 @@ def require_super_user(current_user: User_table = Depends(require_auth)):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request, db: Session = Depends(get_db), current_user: Optional[User_table] = Depends(get_current_user)): async def index(request: Request):
page = int(request.query_params.get("page", 1)) global db_initialization_attempted, db_initialization_successful
per_page = int(request.query_params.get("per_page", 20))
search = request.query_params.get("search", "").strip()
view = request.query_params.get("view", "grid") # grid or list
# Limit per_page to reasonable values # Check if database is ready
per_page = max(10, min(per_page, 100)) if not db_initialization_attempted:
offset = (page - 1) * per_page # Database initialization hasn't started yet
return templates.TemplateResponse("setup.html", {
"request": request,
"status": "initializing",
"message": "Database initialization starting..."
})
# Base query if not db_initialization_successful:
games_query = select(Game_table) # Database initialization failed
count_query = select(func.count(Game_table.id)) return templates.TemplateResponse("setup.html", {
"request": request,
"status": "error",
"message": "Database initialization failed. Please check the logs and try running the setup manually.",
"setup_commands": [
"db-init",
"create-admin"
]
})
# Add search filtering # Database is ready, proceed with normal operation
if search: try:
# Fuzzy-ish search - split search terms and match any of them # Create a database session for this request
search_terms = search.split() with SessionLocal() as db:
search_conditions = [] # Try to get current user (this will handle auth gracefully)
current_user = None
try:
token = None
for term in search_terms: # Check for auth token in cookie
term_pattern = f"%{term}%" if "auth_token" in request.cookies:
term_condition = ( token = request.cookies["auth_token"]
Game_table.title.ilike(term_pattern) |
(Game_table.metadata_obj.has(Metadata_table.title.ilike(term_pattern))) |
(Game_table.metadata_obj.has(Metadata_table.description.ilike(term_pattern))) |
(Game_table.metadata_obj.has(Metadata_table.developer.ilike(term_pattern))) |
(Game_table.metadata_obj.has(Metadata_table.publisher.ilike(term_pattern))) |
(Game_table.metadata_obj.has(Metadata_table.genre.any(Genre_table.name.ilike(term_pattern)))) |
(Game_table.metadata_obj.has(Metadata_table.tags.any(Tags_table.name.ilike(term_pattern))))
)
search_conditions.append(term_condition)
# Match all terms (AND logic) for better relevance if token:
if search_conditions: username = AuthManager.verify_token(token)
from sqlalchemy import and_ if username:
combined_filter = and_(*search_conditions) current_user = AuthManager.get_user_by_username(db, username)
games_query = games_query.where(combined_filter) if current_user and not current_user.is_active:
count_query = count_query.where(combined_filter) current_user = None
except Exception as e:
logging.debug(f"Error getting current user: {e}")
current_user = None
total_games = db.scalar(count_query) page = int(request.query_params.get("page", 1))
# Add alphabetical sorting by default per_page = int(request.query_params.get("per_page", 20))
games = db.scalars(games_query.order_by(Game_table.title).offset(offset).limit(per_page)).all() search = request.query_params.get("search", "").strip()
view = request.query_params.get("view", "grid") # grid or list
# Get user's favorite game IDs if logged in # Limit per_page to reasonable values
user_favorites = set() per_page = max(10, min(per_page, 100))
if current_user and current_user.role != UserRole.DEMO.value: offset = (page - 1) * per_page
user_favorites = {game.id for game in current_user.favorites}
total_pages = (total_games + per_page - 1) // per_page # Base query
games_query = select(Game_table)
count_query = select(func.count(Game_table.id))
return templates.TemplateResponse("index.html", { # Add search filtering
"request": request, if search:
"games": games, # Fuzzy-ish search - split search terms and match any of them
"current_page": page, search_terms = search.split()
"total_pages": total_pages, search_conditions = []
"per_page": per_page,
"search": search, for term in search_terms:
"view": view, term_pattern = f"%{term}%"
"total_games": total_games, term_condition = (
"current_user": current_user, Game_table.title.ilike(term_pattern) |
"is_demo": current_user is None or current_user.role == UserRole.DEMO.value, (Game_table.metadata_obj.has(Metadata_table.title.ilike(term_pattern))) |
"user_favorites": user_favorites (Game_table.metadata_obj.has(Metadata_table.description.ilike(term_pattern))) |
}) (Game_table.metadata_obj.has(Metadata_table.developer.ilike(term_pattern))) |
(Game_table.metadata_obj.has(Metadata_table.publisher.ilike(term_pattern))) |
(Game_table.metadata_obj.has(Metadata_table.genre.any(Genre_table.name.ilike(term_pattern)))) |
(Game_table.metadata_obj.has(Metadata_table.tags.any(Tags_table.name.ilike(term_pattern))))
)
search_conditions.append(term_condition)
# Match all terms (AND logic) for better relevance
if search_conditions:
from sqlalchemy import and_
combined_filter = and_(*search_conditions)
games_query = games_query.where(combined_filter)
count_query = count_query.where(combined_filter)
total_games = db.scalar(count_query)
# Add alphabetical sorting by default
games = db.scalars(games_query.order_by(Game_table.title).offset(offset).limit(per_page)).all()
# Get user's favorite game IDs if logged in
user_favorites = set()
if current_user and current_user.role != UserRole.DEMO.value:
user_favorites = {game.id for game in current_user.favorites}
total_pages = (total_games + per_page - 1) // per_page
return templates.TemplateResponse("index.html", {
"request": request,
"games": games,
"current_page": page,
"total_pages": total_pages,
"per_page": per_page,
"search": search,
"view": view,
"total_games": total_games,
"current_user": current_user,
"is_demo": current_user is None or current_user.role == UserRole.DEMO.value,
"user_favorites": user_favorites
})
except OperationalError as e:
# Database tables don't exist or other database issue
logging.error(f"Database error in index route: {e}")
return templates.TemplateResponse("setup.html", {
"request": request,
"status": "error",
"message": "Database not properly initialized. Please run setup commands.",
"setup_commands": [
"db-init",
"create-admin"
]
})
except Exception as e:
# Other unexpected errors
logging.error(f"Unexpected error in index route: {e}")
return templates.TemplateResponse("setup.html", {
"request": request,
"status": "error",
"message": f"An unexpected error occurred: {str(e)}"
})
@app.post("/login") @app.post("/login")
@@ -1169,6 +1449,106 @@ async def health_check():
"""Health check endpoint for Docker/monitoring""" """Health check endpoint for Docker/monitoring"""
return {"status": "healthy", "service": "DosVault"} return {"status": "healthy", "service": "DosVault"}
@app.get("/profile")
async def user_profile(
request: Request,
db: Session = Depends(get_db),
current_user: User_table = Depends(require_auth)
):
"""Display user profile page"""
return templates.TemplateResponse("user_profile.html", {
"request": request,
"current_user": current_user
})
@app.post("/profile/change-password")
async def change_password(
request: Request,
db: Session = Depends(get_db),
current_user: User_table = Depends(require_auth)
):
"""Change user password"""
try:
# Get JSON data from request
data = await request.json()
current_password = data.get("current_password")
new_password = data.get("new_password")
if not current_password or not new_password:
raise HTTPException(status_code=400, detail="Current password and new password are required")
if len(new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters long")
# Verify current password
if not AuthManager.verify_password(current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
# Update password
current_user.password_hash = AuthManager.get_password_hash(new_password)
db.commit()
logging.info(f"Password changed for user: {current_user.username}")
return {"message": "Password updated successfully"}
except HTTPException:
raise
except Exception as e:
logging.error(f"Error changing password for user {current_user.username}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.post("/profile/update-info")
async def update_profile_info(
request: Request,
db: Session = Depends(get_db),
current_user: User_table = Depends(require_super_user)
):
"""Update user profile information (super users only)"""
try:
# Get JSON data from request
data = await request.json()
username = data.get("username", "").strip()
email = data.get("email", "").strip()
if not username or not email:
raise HTTPException(status_code=400, detail="Username and email are required")
if len(username) < 3:
raise HTTPException(status_code=400, detail="Username must be at least 3 characters long")
# Check if username already exists (excluding current user)
existing_user = db.query(User_table).filter(
User_table.username == username,
User_table.id != current_user.id
).first()
if existing_user:
raise HTTPException(status_code=400, detail="Username already exists")
# Check if email already exists (excluding current user)
existing_email = db.query(User_table).filter(
User_table.email == email,
User_table.id != current_user.id
).first()
if existing_email:
raise HTTPException(status_code=400, detail="Email already exists")
# Update user information
old_username = current_user.username
current_user.username = username
current_user.email = email
db.commit()
logging.info(f"Profile information updated for user: {old_username} -> {username} ({email})")
return {"message": "Profile updated successfully"}
except HTTPException:
raise
except Exception as e:
logging.error(f"Error updating profile for user {current_user.username}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/auth/token") @app.get("/api/auth/token")
async def get_auth_token( async def get_auth_token(
request: Request, request: Request,
@@ -1179,6 +1559,39 @@ async def get_auth_token(
token = request.cookies.get("auth_token", "") token = request.cookies.get("auth_token", "")
return {"token": token, "username": current_user.username} return {"token": token, "username": current_user.username}
# Catch-all route for 404 errors - this must be the last route
@app.get("/{path:path}", response_class=HTMLResponse)
async def catch_all_404(request: Request, path: str):
"""Catch-all route for 404 errors"""
# Get current user for error page context
current_user = None
try:
if "auth_token" in request.cookies:
token = request.cookies["auth_token"]
with SessionLocal() as db:
username = AuthManager.verify_token(token)
if username:
current_user = AuthManager.get_user_by_username(db, username)
if current_user and not current_user.is_active:
current_user = None
except Exception:
pass
error_details = {
"request": request,
"current_user": current_user,
"error_code": 404,
"error_title": "Page Not Found",
"error_description": f"The page '/{path}' doesn't exist or has been moved.",
"error_details": None
}
return templates.TemplateResponse(
"error.html",
error_details,
status_code=404
)
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host=config.host, port=config.port) uvicorn.run(app, host=config.host, port=config.port)

View File

@@ -319,9 +319,9 @@
</div> </div>
{% if current_user %} {% if current_user %}
<span class="text-sm text-primary"> <a href="/profile" class="text-sm text-accent hover:text-accent-hover transition-colors">
Welcome, {{ current_user.username }} {{ current_user.username }}
</span> </a>
<button onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm transition-colors"> <button onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm transition-colors">
Logout Logout
</button> </button>
@@ -403,8 +403,12 @@
</div> </div>
{% if current_user %} {% if current_user %}
<div class="text-base font-medium text-primary">{{ current_user.username }}</div> <div class="mb-3">
<button onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm w-full mt-3 transition-colors"> <a href="/profile" class="text-base font-medium text-accent hover:text-accent-hover transition-colors">
{{ current_user.username }}
</a>
</div>
<button onclick="logout()" class="bg-red-600 hover:bg-red-700 px-4 py-2 rounded text-sm w-full transition-colors">
Logout Logout
</button> </button>
{% else %} {% else %}

211
templates/error.html Normal file
View File

@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if error_code %}{{ error_code }} - {% endif %}{{ error_title or "Error" }} - DosVault</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root {
/* Default Dark Theme */
--primary-bg: #111827;
--secondary-bg: #1f2937;
--tertiary-bg: #374151;
--accent-bg: #2563eb;
--accent-hover: #1d4ed8;
--text-primary: #ffffff;
--text-secondary: #9ca3af;
--text-accent: #60a5fa;
--border-color: #4b5563;
--success-color: #059669;
--warning-color: #d97706;
--danger-color: #dc2626;
--gradient-from: #1f2937;
--gradient-to: #111827;
}
/* Apply CSS custom properties */
body {
background-color: var(--primary-bg);
color: var(--text-primary);
}
.bg-primary { background-color: var(--primary-bg); }
.bg-secondary { background-color: var(--secondary-bg); }
.bg-tertiary { background-color: var(--tertiary-bg); }
.bg-accent { background-color: var(--accent-bg); }
.bg-accent:hover { background-color: var(--accent-hover); }
.bg-accent-hover { background-color: var(--accent-hover); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-accent { color: var(--text-accent); }
.border-theme { border-color: var(--border-color); }
.bg-gradient-theme { background: linear-gradient(to bottom right, var(--gradient-from), var(--gradient-to)); }
/* Fade-in animation */
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Glitch effect for error code */
.glitch {
font-size: 4rem;
font-weight: bold;
text-transform: uppercase;
position: relative;
text-shadow: 0.05em 0 0 #00ffff, -0.03em -0.04em 0 #ff00ff,
0.025em 0.04em 0 #ffff00;
animation: glitch 500ms infinite;
}
@keyframes glitch {
0% {
text-shadow: 0.05em 0 0 #00ffff, -0.03em -0.04em 0 #ff00ff,
0.025em 0.04em 0 #ffff00;
}
15% {
text-shadow: 0.05em 0 0 #00ffff, -0.03em -0.04em 0 #ff00ff,
0.025em 0.04em 0 #ffff00;
}
16% {
text-shadow: -0.05em -0.025em 0 #00ffff, 0.025em 0.035em 0 #ff00ff,
-0.05em -0.05em 0 #ffff00;
}
49% {
text-shadow: -0.05em -0.025em 0 #00ffff, 0.025em 0.035em 0 #ff00ff,
-0.05em -0.05em 0 #ffff00;
}
50% {
text-shadow: 0.05em 0.035em 0 #00ffff, 0.03em 0 0 #ff00ff,
0 -0.04em 0 #ffff00;
}
99% {
text-shadow: 0.05em 0.035em 0 #00ffff, 0.03em 0 0 #ff00ff,
0 -0.04em 0 #ffff00;
}
100% {
text-shadow: 0.05em 0 0 #00ffff, -0.03em -0.04em 0 #ff00ff,
0.025em 0.04em 0 #ffff00;
}
}
</style>
</head>
<body class="bg-primary text-primary min-h-screen">
<div class="min-h-screen flex items-center justify-center bg-gradient-theme py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-lg w-full text-center animate-fade-in">
<!-- DosVault Logo -->
<div class="mx-auto h-20 w-20 flex items-center justify-center mb-8">
<svg class="w-16 h-16 text-accent opacity-50" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Vault door -->
<circle cx="16" cy="16" r="14" fill="currentColor" opacity="0.1"/>
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="2" fill="none"/>
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.3"/>
<!-- DOS-style pixels -->
<rect x="6" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
<rect x="10" y="6" width="2" height="2" fill="currentColor" opacity="0.8"/>
<rect x="20" y="6" width="2" height="2" fill="currentColor" opacity="0.8"/>
<rect x="24" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
<rect x="6" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
<rect x="24" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
<!-- Handle -->
<rect x="20" y="14" width="6" height="4" rx="2" fill="currentColor" opacity="0.7"/>
<circle cx="24" cy="16" r="1" fill="currentColor"/>
<!-- Error X overlay -->
<line x1="8" y1="8" x2="24" y2="24" stroke="currentColor" stroke-width="2" opacity="0.8"/>
<line x1="24" y1="8" x2="8" y2="24" stroke="currentColor" stroke-width="2" opacity="0.8"/>
</svg>
</div>
<!-- Error Code with Glitch Effect -->
{% if error_code %}
<div class="glitch text-accent mb-4">{{ error_code }}</div>
{% endif %}
<!-- Error Title -->
<h1 class="text-3xl font-bold text-primary mb-4">
{{ error_title or "Oops! Something went wrong" }}
</h1>
<!-- Error Description -->
<p class="text-secondary text-lg mb-8 max-w-md mx-auto">
{{ error_description or "The page you're looking for doesn't exist or has been moved." }}
</p>
<!-- Error Details (if available) -->
{% if error_details %}
<div class="bg-tertiary border border-theme rounded-lg p-4 mb-8 text-left">
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-warning-color mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<h3 class="text-sm font-medium text-primary">Error Details</h3>
</div>
<p class="text-sm text-secondary font-mono">{{ error_details }}</p>
</div>
{% endif %}
<!-- Action Buttons -->
<div class="space-y-4 sm:space-y-0 sm:space-x-4 sm:flex sm:justify-center">
<a href="/"
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-accent hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5a2 2 0 012-2h4a2 2 0 012 2v0M8 5a2 2 0 012-2h4a2 2 0 012 2v0"></path>
</svg>
Browse Games
</a>
<button onclick="goBack()"
class="inline-flex items-center px-6 py-3 border border-theme text-base font-medium rounded-md text-primary bg-secondary hover:bg-tertiary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Go Back
</button>
</div>
<!-- Additional Help -->
<div class="mt-12 text-center">
<p class="text-sm text-secondary mb-4">Still having trouble?</p>
<div class="space-x-6">
{% if current_user and current_user.role == "super" %}
<a href="/admin" class="text-accent hover:text-accent-hover text-sm transition-colors">
Admin Panel
</a>
{% endif %}
<a href="/login" class="text-accent hover:text-accent-hover text-sm transition-colors">
Login
</a>
<span class="text-secondary text-sm"></span>
<span class="text-secondary text-sm">DosVault v1.0</span>
</div>
</div>
</div>
</div>
<script>
function goBack() {
// Try to go back in history, fallback to home page
if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = '/';
}
}
</script>
</body>
</html>

View File

@@ -442,22 +442,23 @@
{% endif %} {% endif %}
<!-- Game Detail Overlay --> <!-- Game Detail Overlay -->
<div id="gameDetailOverlay" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4 overflow-y-auto backdrop-blur-sm"> <div id="gameDetailOverlay" class="hidden fixed inset-0 bg-black bg-opacity-75 z-50 overflow-y-auto backdrop-blur-sm">
<div class="bg-secondary rounded-lg max-w-6xl max-h-full w-full overflow-hidden relative shadow-2xl border border-theme animate-fade-in"> <div class="min-h-screen flex items-start sm:items-center justify-center p-4 py-8 sm:py-4">
<!-- Close Button --> <div class="bg-secondary rounded-lg max-w-6xl w-full overflow-hidden relative shadow-2xl border border-theme animate-fade-in">
<button onclick="closeGameDetail()" class="absolute top-4 right-4 text-primary hover:text-secondary text-2xl z-30 bg-primary bg-opacity-20 hover:bg-opacity-30 rounded-full w-10 h-10 flex items-center justify-center transition-all"> <!-- Close Button -->
&times; <button onclick="closeGameDetail()" class="absolute top-2 left-2 text-primary hover:text-secondary text-2xl z-30 bg-primary bg-opacity-20 hover:bg-opacity-30 rounded-full w-10 h-10 flex items-center justify-center transition-all">
</button> &times;
</button>
<!-- Loading State --> <!-- Loading State -->
<div id="gameDetailLoading" class="flex items-center justify-center h-96"> <div id="gameDetailLoading" class="flex items-center justify-center h-96">
<div class="text-primary">Loading game details...</div> <div class="text-primary">Loading game details...</div>
</div> </div>
<!-- Game Detail Content --> <!-- Game Detail Content -->
<div id="gameDetailContent" class="hidden max-h-screen overflow-y-auto"> <div id="gameDetailContent" class="hidden">
<!-- Header --> <!-- Header -->
<div class="border-b border-theme p-6"> <div class="border-b border-theme p-6 pl-16">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<h1 id="gameTitle" class="text-3xl font-bold mb-2 text-primary"></h1> <h1 id="gameTitle" class="text-3xl font-bold mb-2 text-primary"></h1>
@@ -538,6 +539,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>

222
templates/login.html Normal file
View File

@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - DosVault</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root {
/* Default Dark Theme */
--primary-bg: #111827;
--secondary-bg: #1f2937;
--tertiary-bg: #374151;
--accent-bg: #2563eb;
--accent-hover: #1d4ed8;
--text-primary: #ffffff;
--text-secondary: #9ca3af;
--text-accent: #60a5fa;
--border-color: #4b5563;
--success-color: #059669;
--warning-color: #d97706;
--danger-color: #dc2626;
--gradient-from: #1f2937;
--gradient-to: #111827;
}
/* Apply CSS custom properties */
body {
background-color: var(--primary-bg);
color: var(--text-primary);
}
.bg-primary { background-color: var(--primary-bg); }
.bg-secondary { background-color: var(--secondary-bg); }
.bg-tertiary { background-color: var(--tertiary-bg); }
.bg-accent { background-color: var(--accent-bg); }
.bg-accent:hover { background-color: var(--accent-hover); }
.bg-accent-hover { background-color: var(--accent-hover); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-accent { color: var(--text-accent); }
.border-theme { border-color: var(--border-color); }
.bg-gradient-theme { background: linear-gradient(to bottom right, var(--gradient-from), var(--gradient-to)); }
/* Fade-in animation */
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body class="bg-primary text-primary min-h-screen">
<div class="min-h-screen flex items-center justify-center bg-gradient-theme py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 animate-fade-in">
<div>
<!-- DosVault Logo -->
<div class="mx-auto h-24 w-24 flex items-center justify-center">
<svg class="w-20 h-20 text-accent" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Vault door -->
<circle cx="16" cy="16" r="14" fill="currentColor" opacity="0.1"/>
<circle cx="16" cy="16" r="12" stroke="currentColor" stroke-width="2" fill="none"/>
<circle cx="16" cy="16" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
<circle cx="16" cy="16" r="4" fill="currentColor" opacity="0.3"/>
<!-- DOS-style pixels -->
<rect x="6" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
<rect x="10" y="6" width="2" height="2" fill="currentColor" opacity="0.8"/>
<rect x="20" y="6" width="2" height="2" fill="currentColor" opacity="0.8"/>
<rect x="24" y="6" width="2" height="2" fill="currentColor" opacity="0.6"/>
<rect x="6" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
<rect x="24" y="24" width="2" height="2" fill="currentColor" opacity="0.6"/>
<!-- Handle -->
<rect x="20" y="14" width="6" height="4" rx="2" fill="currentColor" opacity="0.7"/>
<circle cx="24" cy="16" r="1" fill="currentColor"/>
</svg>
</div>
<h1 class="mt-6 text-center text-3xl font-extrabold text-primary">
Welcome to DosVault
</h1>
<p class="mt-2 text-center text-sm text-secondary">
Sign in to access your DOS game collection
</p>
</div>
<!-- Login Form -->
<form class="mt-8 space-y-6" onsubmit="handleLogin(event)">
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="username" class="sr-only">Username</label>
<input id="username" name="username" type="text" required
class="appearance-none rounded-none relative block w-full px-3 py-2 bg-secondary border border-theme border-b-0 rounded-t-md placeholder-secondary text-primary focus:outline-none focus:ring-accent focus:border-accent focus:z-10 sm:text-sm"
placeholder="Username">
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input id="password" name="password" type="password" required
class="appearance-none rounded-none relative block w-full px-3 py-2 bg-secondary border border-theme rounded-b-md placeholder-secondary text-primary focus:outline-none focus:ring-accent focus:border-accent focus:z-10 sm:text-sm"
placeholder="Password">
</div>
</div>
<div id="login-status" class="text-sm text-center hidden"></div>
<div>
<button type="submit" id="login-btn"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-accent hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent transition-colors">
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<svg class="h-5 w-5 text-accent-hover group-hover:text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
</span>
<span class="login-text">Sign in</span>
<span class="login-loading hidden">Signing in...</span>
</button>
</div>
<div class="text-center">
<a href="/" class="text-accent hover:text-accent-hover text-sm transition-colors">
← Back to game library
</a>
</div>
</form>
<!-- Demo Mode Notice -->
<div class="mt-8 p-4 bg-tertiary border border-theme rounded-md">
<div class="flex items-center">
<svg class="w-5 h-5 text-warning-color mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<div>
<h3 class="text-sm font-medium text-primary">Demo Mode Available</h3>
<p class="text-xs text-secondary mt-1">You can browse the game library without logging in, but downloads require authentication.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Check if there's an error message in URL params
const urlParams = new URLSearchParams(window.location.search);
const errorMessage = urlParams.get('error');
const redirectUrl = urlParams.get('redirect') || '/';
if (errorMessage) {
const statusDiv = document.getElementById('login-status');
statusDiv.textContent = decodeURIComponent(errorMessage);
statusDiv.className = 'text-sm text-center text-red-400';
statusDiv.classList.remove('hidden');
}
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const btn = document.getElementById('login-btn');
const status = document.getElementById('login-status');
const loginText = btn.querySelector('.login-text');
const loginLoading = btn.querySelector('.login-loading');
// Disable form and show loading
btn.disabled = true;
loginText.classList.add('hidden');
loginLoading.classList.remove('hidden');
status.textContent = '';
status.classList.add('hidden');
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
try {
const response = await fetch('/login', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('authToken', data.access_token);
// Show success message briefly
status.textContent = 'Login successful! Redirecting...';
status.className = 'text-sm text-center text-green-400';
status.classList.remove('hidden');
// Redirect after a short delay
setTimeout(() => {
window.location.href = redirectUrl;
}, 1000);
} else {
const errorData = await response.json();
status.textContent = errorData.detail || 'Login failed. Please check your credentials.';
status.className = 'text-sm text-center text-red-400';
status.classList.remove('hidden');
}
} catch (error) {
console.error('Login error:', error);
status.textContent = 'Network error. Please try again.';
status.className = 'text-sm text-center text-red-400';
status.classList.remove('hidden');
} finally {
// Re-enable form
btn.disabled = false;
loginText.classList.remove('hidden');
loginLoading.classList.add('hidden');
}
}
</script>
</body>
</html>

110
templates/setup.html Normal file
View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}DosVault - Setup{% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center px-4">
<div class="max-w-md w-full space-y-8 text-center">
<div>
<div class="mx-auto w-24 h-24 mb-6">
<svg class="w-full h-full text-accent" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<h2 class="text-3xl font-bold text-primary mb-2">DosVault Setup</h2>
{% if status == "initializing" %}
<div class="animate-spin inline-block w-8 h-8 border-4 border-accent border-t-transparent rounded-full mb-4"></div>
<p class="text-secondary text-lg">{{ message }}</p>
<p class="text-secondary text-sm mt-2">This may take a few moments...</p>
<!-- Auto refresh every 3 seconds while initializing -->
<script>
setTimeout(function() {
window.location.reload();
}, 3000);
</script>
{% elif status == "error" %}
<div class="w-16 h-16 mx-auto mb-4 text-red-500">
<svg fill="currentColor" viewBox="0 0 24 24" class="w-full h-full">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
</div>
<p class="text-red-400 text-lg mb-4">{{ message }}</p>
{% if setup_commands %}
<div class="bg-secondary p-6 rounded-lg border border-tertiary text-left">
<h3 class="text-primary text-lg font-semibold mb-4">Manual Setup Instructions</h3>
<p class="text-secondary text-sm mb-4">
Please run the following commands in your terminal to set up DosVault:
</p>
<div class="space-y-3">
{% for command in setup_commands %}
<div class="bg-primary p-3 rounded border border-border-color">
<code class="text-accent text-sm font-mono">{{ command }}</code>
</div>
{% endfor %}
</div>
<div class="mt-6 p-4 bg-yellow-900 border border-yellow-700 rounded">
<p class="text-yellow-200 text-sm">
<strong>Note:</strong> After running these commands, refresh this page to continue.
</p>
</div>
</div>
<div class="mt-6">
<button onclick="window.location.reload()"
class="px-6 py-3 bg-accent hover:bg-accent-hover text-white rounded-lg font-medium transition-colors">
Retry Setup
</button>
</div>
{% endif %}
{% else %}
<div class="w-16 h-16 mx-auto mb-4 text-green-500">
<svg fill="currentColor" viewBox="0 0 24 24" class="w-full h-full">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
</div>
<p class="text-green-400 text-lg mb-4">Setup Complete!</p>
<p class="text-secondary text-sm">DosVault has been initialized successfully.</p>
<div class="mt-6">
<a href="/" class="px-6 py-3 bg-accent hover:bg-accent-hover text-white rounded-lg font-medium transition-colors inline-block">
Continue to DosVault
</a>
</div>
{% endif %}
</div>
<!-- Default credentials notice for successful setup -->
{% if status == "initializing" or status == "error" %}
<div class="mt-8 p-4 bg-blue-900 border border-blue-700 rounded-lg">
<h4 class="text-blue-200 font-semibold mb-2">Default Admin Credentials</h4>
<p class="text-blue-300 text-sm">
Username: <code class="bg-blue-800 px-1 py-0.5 rounded">admin</code><br>
Password: <code class="bg-blue-800 px-1 py-0.5 rounded">admin123</code>
</p>
<p class="text-blue-400 text-xs mt-2">
<strong>Important:</strong> Please change these credentials after your first login!
</p>
</div>
{% endif %}
</div>
</div>
<style>
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
{% endblock %}

392
templates/user_profile.html Normal file
View File

@@ -0,0 +1,392 @@
{% extends "base.html" %}
{% block title %}User Profile - DosVault{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 text-primary">User Profile</h1>
<p class="text-secondary">Manage your account settings and preferences</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Profile Information Card -->
<div class="lg:col-span-1">
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-6">
<div class="w-16 h-16 bg-accent rounded-full flex items-center justify-center mr-4">
<span class="text-2xl font-bold text-white">{{ current_user.username[0].upper() }}</span>
</div>
<div>
<h2 class="text-xl font-semibold text-primary">{{ current_user.username }}</h2>
<p class="text-secondary">{{ current_user.email }}</p>
<span class="px-2 py-1 rounded text-xs text-white mt-1 inline-block
{% if current_user.role == 'super' %}bg-danger-color
{% elif current_user.role == 'normal' %}bg-accent
{% else %}bg-warning-color{% endif %}">
{{ current_user.role.upper() }} USER
</span>
</div>
</div>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-secondary">Joined:</span>
<span class="text-primary">{{ current_user.created_at.strftime('%B %d, %Y') if current_user.created_at else 'Unknown' }}</span>
</div>
{% if current_user.last_login %}
<div class="flex justify-between text-sm">
<span class="text-secondary">Last Login:</span>
<span class="text-primary">{{ current_user.last_login.strftime('%B %d, %Y at %I:%M %p') }}</span>
</div>
{% endif %}
<div class="flex justify-between text-sm">
<span class="text-secondary">Account Status:</span>
<span class="{% if current_user.is_active %}text-green-400{% else %}text-red-400{% endif %}">
{% if current_user.is_active %}Active{% else %}Inactive{% endif %}
</span>
</div>
</div>
</div>
</div>
<!-- Account Management -->
<div class="lg:col-span-2">
<div class="space-y-6">
<!-- Profile Information Edit Section (Super users only) -->
{% if current_user.role == "super" %}
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-6 h-6 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<h3 class="text-lg font-semibold text-primary">Edit Profile Information</h3>
</div>
<p class="text-secondary text-sm mb-4">Update your username and email address.</p>
<form onsubmit="updateProfile(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-secondary mb-1">Username</label>
<input type="text" id="edit-username" value="{{ current_user.username }}" required
class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary placeholder-secondary focus:outline-none focus:ring-2 focus:ring-accent">
<p class="text-xs text-secondary mt-1">Choose a unique username for your account.</p>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1">Email Address</label>
<input type="email" id="edit-email" value="{{ current_user.email }}" required
class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary placeholder-secondary focus:outline-none focus:ring-2 focus:ring-accent">
<p class="text-xs text-secondary mt-1">A valid email address for account recovery.</p>
</div>
<div class="flex items-center space-x-4">
<button type="submit" id="profile-btn"
class="px-6 py-2 bg-green-600 hover:bg-green-700 rounded font-medium text-white transition-colors">
<span class="profile-text">Update Profile</span>
<span class="profile-loading hidden">Updating...</span>
</button>
<div id="profile-status" class="text-sm"></div>
</div>
</form>
</div>
{% endif %}
<!-- Password Change Section -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-6 h-6 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m0 0a2 2 0 012 2m-2-2h-6m6 0v6a2 2 0 01-2 2H9a2 2 0 01-2-2V9a2 2 0 012-2h2m0 0V7a2 2 0 012-2"/>
</svg>
<h3 class="text-lg font-semibold text-primary">Change Password</h3>
</div>
<p class="text-secondary text-sm mb-4">Update your password to keep your account secure.</p>
<form onsubmit="changePassword(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-secondary mb-1">Current Password</label>
<input type="password" id="current-password" required
class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary placeholder-secondary focus:outline-none focus:ring-2 focus:ring-accent">
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1">New Password</label>
<input type="password" id="new-password" required minlength="6"
class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary placeholder-secondary focus:outline-none focus:ring-2 focus:ring-accent">
<p class="text-xs text-secondary mt-1">Password must be at least 6 characters long.</p>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-1">Confirm New Password</label>
<input type="password" id="confirm-password" required
class="w-full px-3 py-2 bg-tertiary border border-theme rounded text-primary placeholder-secondary focus:outline-none focus:ring-2 focus:ring-accent">
</div>
<div class="flex items-center space-x-4">
<button type="submit" id="password-btn"
class="px-6 py-2 bg-accent hover:bg-accent-hover rounded font-medium text-white transition-colors">
<span class="password-text">Update Password</span>
<span class="password-loading hidden">Updating...</span>
</button>
<div id="password-status" class="text-sm"></div>
</div>
</form>
</div>
<!-- Account Statistics (for non-demo users) -->
{% if current_user.role != "demo" %}
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-6 h-6 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<h3 class="text-lg font-semibold text-primary">Your Statistics</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="user-stats">
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<div class="flex items-center">
<div class="text-2xl text-accent mr-3">❤️</div>
<div>
<p class="text-lg font-bold text-primary" id="favorites-count">{{ current_user.favorites|length }}</p>
<p class="text-secondary text-sm">Favorite Games</p>
</div>
</div>
</div>
<div class="bg-tertiary p-4 rounded-lg border border-theme">
<div class="flex items-center">
<div class="text-2xl text-accent mr-3">⬇️</div>
<div>
<p class="text-lg font-bold text-primary">N/A</p>
<p class="text-secondary text-sm">Downloads</p>
</div>
</div>
</div>
</div>
<div class="mt-4">
<a href="/favorites" class="inline-flex items-center px-4 py-2 bg-accent hover:bg-accent-hover rounded font-medium text-white transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
View My Favorites
</a>
</div>
</div>
{% endif %}
<!-- Quick Actions -->
<div class="bg-secondary rounded-lg p-6 border border-theme">
<div class="flex items-center mb-4">
<svg class="w-6 h-6 text-accent mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<h3 class="text-lg font-semibold text-primary">Quick Actions</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<a href="/" class="flex items-center p-3 bg-tertiary hover:bg-accent rounded-lg transition-colors group">
<div class="text-2xl mr-3 group-hover:text-white">🎮</div>
<div>
<p class="font-medium text-primary group-hover:text-white">Browse Games</p>
<p class="text-sm text-secondary group-hover:text-gray-200">Explore the library</p>
</div>
</a>
{% if current_user.role != "demo" %}
<a href="/favorites" class="flex items-center p-3 bg-tertiary hover:bg-accent rounded-lg transition-colors group">
<div class="text-2xl mr-3 group-hover:text-white">❤️</div>
<div>
<p class="font-medium text-primary group-hover:text-white">My Favorites</p>
<p class="text-sm text-secondary group-hover:text-gray-200">View saved games</p>
</div>
</a>
{% endif %}
{% if current_user.role == "super" %}
<a href="/admin" class="flex items-center p-3 bg-tertiary hover:bg-accent rounded-lg transition-colors group">
<div class="text-2xl mr-3 group-hover:text-white">⚙️</div>
<div>
<p class="font-medium text-primary group-hover:text-white">Admin Panel</p>
<p class="text-sm text-secondary group-hover:text-gray-200">System management</p>
</div>
</a>
{% endif %}
<button onclick="logout()" class="flex items-center p-3 bg-red-600 hover:bg-red-700 rounded-lg transition-colors group">
<div class="text-2xl mr-3 text-white">🚪</div>
<div>
<p class="font-medium text-white">Sign Out</p>
<p class="text-sm text-red-200">End your session</p>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
async function changePassword(event) {
event.preventDefault();
const currentPassword = document.getElementById('current-password').value;
const newPassword = document.getElementById('new-password').value;
const confirmPassword = document.getElementById('confirm-password').value;
const btn = document.getElementById('password-btn');
const status = document.getElementById('password-status');
const passwordText = btn.querySelector('.password-text');
const passwordLoading = btn.querySelector('.password-loading');
// Client-side validation
if (newPassword !== confirmPassword) {
status.textContent = 'New passwords do not match';
status.className = 'text-sm text-red-400';
return;
}
if (newPassword.length < 6) {
status.textContent = 'Password must be at least 6 characters long';
status.className = 'text-sm text-red-400';
return;
}
// Disable form and show loading
btn.disabled = true;
passwordText.classList.add('hidden');
passwordLoading.classList.remove('hidden');
status.textContent = '';
try {
const response = await fetch('/profile/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getCookie('auth_token')}`
},
credentials: 'include',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
const result = await response.json();
if (response.ok) {
status.textContent = 'Password updated successfully!';
status.className = 'text-sm text-green-400';
// Clear form
document.getElementById('current-password').value = '';
document.getElementById('new-password').value = '';
document.getElementById('confirm-password').value = '';
} else {
status.textContent = result.detail || 'Failed to update password';
status.className = 'text-sm text-red-400';
}
} catch (error) {
console.error('Password change error:', error);
status.textContent = 'Network error. Please try again.';
status.className = 'text-sm text-red-400';
} finally {
// Re-enable form
btn.disabled = false;
passwordText.classList.remove('hidden');
passwordLoading.classList.add('hidden');
}
}
async function updateProfile(event) {
event.preventDefault();
const username = document.getElementById('edit-username').value.trim();
const email = document.getElementById('edit-email').value.trim();
const btn = document.getElementById('profile-btn');
const status = document.getElementById('profile-status');
const profileText = btn.querySelector('.profile-text');
const profileLoading = btn.querySelector('.profile-loading');
// Basic client-side validation
if (!username || !email) {
status.textContent = 'Username and email are required';
status.className = 'text-sm text-red-400';
return;
}
if (username.length < 3) {
status.textContent = 'Username must be at least 3 characters long';
status.className = 'text-sm text-red-400';
return;
}
// Disable form and show loading
btn.disabled = true;
profileText.classList.add('hidden');
profileLoading.classList.remove('hidden');
status.textContent = '';
try {
const response = await fetch('/profile/update-info', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getCookie('auth_token')}`
},
credentials: 'include',
body: JSON.stringify({
username: username,
email: email
})
});
const result = await response.json();
if (response.ok) {
status.textContent = 'Profile updated successfully! Refreshing page...';
status.className = 'text-sm text-green-400';
// Refresh page after a short delay to show the updated info
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
status.textContent = result.detail || 'Failed to update profile';
status.className = 'text-sm text-red-400';
}
} catch (error) {
console.error('Profile update error:', error);
status.textContent = 'Network error. Please try again.';
status.className = 'text-sm text-red-400';
} finally {
// Re-enable form
btn.disabled = false;
profileText.classList.remove('hidden');
profileLoading.classList.add('hidden');
}
}
// Logout function (if not already defined in base template)
async function logout() {
try {
await fetch('/logout', { method: 'POST' });
} catch (error) {
console.error('Logout error:', error);
}
localStorage.removeItem('authToken');
window.location.href = '/';
}
</script>
{% endblock %}