Add OIDC SSO, exact expiration dates, memory optimization, and major UI/UX enhancements

This update introduces comprehensive OpenID Connect (OIDC) Single Sign-On support with dynamic configuration via the database and full frontend/backend integration. Key additions include:

- OIDC SSO login via external providers (e.g., Google, Keycloak), with automatic user provisioning and session linking.
- Admin settings UI for enabling/disabling SSO and managing provider credentials.
- Provider-branded SSO buttons with dynamic labels, icons, and styles.
- Exact warranty expiration date support alongside duration-based input, with full validation and UI enhancements.
- Full UI responsiveness for warranty field updates, tag creation, and note editing.
- Memory usage optimization for low-resource deployments via configurable modes (optimized, ultra-light, performance).
- Numerous fixes for SSO authentication flow, UI sync issues, database constraints, and modal interactions.
- Upgraded dependencies for security, performance, and compatibility (Flask 3.0.3, Gunicorn 23.0.0, etc.).
- Frontend improvements: Chart.js loading fix, tooltips for long product names, and dark/light mode-compatible footer.

This release significantly improves authentication flexibility, performance, and user experience across all major components.
This commit is contained in:
sassanix
2025-06-01 15:02:43 -03:00
parent b65428e1af
commit 6416ff51e6
34 changed files with 4232 additions and 990 deletions

View File

@@ -1,5 +1,165 @@
# Changelog
## 0.9.9.9 - 2025-06-01
### Added
- **OIDC SSO (Single Sign-On) Integration:**
- Implemented OpenID Connect (OIDC) authentication flow using Authlib, allowing users to log in via external OIDC providers (e.g., Google, Keycloak).
- **Backend (`app.py`):**
- OIDC client configuration (Client ID, Secret, Issuer URL, Scope, Provider Name) is dynamically loaded from the `site_settings` database table at application startup.
- OIDC feature can be enabled/disabled globally via the `oidc_enabled` setting in the database.
- Added OIDC-specific routes: `/api/oidc/login` (initiates login) and `/api/oidc/callback` (handles redirect from IdP).
- User provisioning: If an OIDC user doesn't exist locally, a new user account is created. Existing OIDC users are logged in.
- Updated Admin Settings API (`/api/admin/settings`) to manage these OIDC parameters.
- Database migration `023_add_oidc_columns_to_users.sql` adds `oidc_sub`, `oidc_issuer` to `users` table and `login_method` to `user_sessions` table for linking local users to OIDC identities.
- **Frontend:**
- **Login Page (`login.html`, `auth.js`):** Added an "Login with SSO Provider" button that initiates the OIDC flow.
- **Admin Settings Page (`settings-new.html`, `settings-new.js`):** New "OIDC SSO Configuration" section for admins to enable/disable OIDC and configure provider details.
- **Auth Redirect Page (`auth-redirect.html`):** Handles the token received from the `/api/oidc/callback` and logs the user in.
- **Dependencies (`requirements.txt`):** Added `Authlib` and `requests`.
- _Files: `backend/app.py`, `backend/migrations/023_add_oidc_columns_to_users.sql`, `backend/requirements.txt`, `frontend/login.html`, `frontend/auth.js`, `frontend/settings-new.html`, `frontend/settings-new.js`, `frontend/auth-redirect.html`_
- **SSO Registration Control:** When the site's registration setting is disabled, new users cannot register via SSO. Only existing users who already have accounts can use SSO to log in. This provides administrators with granular control over user registration while maintaining SSO functionality for existing users.
- **Backend:** Enhanced OIDC callback handler to check the `registration_enabled` site setting before creating new user accounts via SSO.
- **Frontend:** Added appropriate error message for users when SSO registration is blocked due to disabled registrations.
- First user registration via SSO is still allowed regardless of the setting (same as regular registration).
- _Files: `backend/oidc_handler.py`, `frontend/auth-redirect.html`_
- **Provider-Specific SSO Button Branding:** Enhanced the SSO login button to show provider-specific branding and icons for well-known OIDC providers.
- **Supported Providers:** Google, GitHub, Microsoft/Azure, Facebook, Twitter, LinkedIn, Apple, Discord, GitLab, Bitbucket, Keycloak, and Okta.
- **Dynamic Button Text:** Shows "Login with Google", "Login with GitHub", etc., based on the configured `OIDC_PROVIDER_NAME`.
- **Provider Icons:** Uses appropriate Font Awesome icons for each provider (e.g., Google logo for Google, GitHub logo for GitHub).
- **Brand Colors:** Each provider button uses authentic brand colors with proper hover effects.
- **Fallback Support:** Unknown providers display the generic "Login with SSO Provider" button with default styling.
- **Environment Integration:** Works automatically with the `OIDC_PROVIDER_NAME` environment variable and database settings.
- _Files: `frontend/login.html`, `backend/oidc_handler.py`_
- **Exact Expiration Date Input:** Enhanced warranty entry to support exact expiration dates as an alternative to duration-based input.
- **New Warranty Input Method:** Users can now choose between "Warranty Duration" (years/months/days) and "Exact Expiration Date" options when adding or editing warranties.
- **UI Enhancements:** Added radio button selection for warranty entry method with smooth form field transitions and validation.
- **Backend Support:** Both `add_warranty` and `update_warranty` API endpoints now handle `exact_expiration_date` parameter alongside duration fields.
- **Smart Duration Display:** Warranty cards automatically calculate and display duration text even when using exact expiration dates, eliminating "N/A" values.
- **Form Validation:** Enhanced validation ensures either exact date or duration is provided for non-lifetime warranties, with appropriate error messages.
- **Date Calculation:** Added robust date calculation helper function that properly handles different month lengths, leap years, and edge cases.
- _Files: `frontend/index.html`, `frontend/script.js`, `frontend/style.css`, `backend/app.py`_
- **Complete Field Updates:** All warranty fields (product info, dates, serial numbers, tags, documents) update immediately in the interface.
- **Fallback Safety:** Maintains fallback to server reload if local update fails, ensuring data consistency.
- _Files: `frontend/script.js`_
- **Memory Usage Optimization:** Significantly reduced RAM consumption for better performance on resource-constrained servers.
- **Dynamic Memory Modes:** Configurable via `WARRACKER_MEMORY_MODE` environment variable with "optimized" (default), "ultra-light", and "performance" options.
- **Optimized Mode:** 2 gevent workers with 128MB memory limits (~60-80MB total RAM usage).
- **Ultra-Light Mode:** 1 sync worker with 64MB memory limit (~40-50MB total RAM usage for minimal servers).
- **Performance Mode:** 4 gevent workers with 256MB memory limits (~150-200MB total RAM usage for high-performance servers).
- **Worker Configuration:** Added memory limits per worker, connection pooling optimization, and preload_app for memory sharing.
- **Database Pool:** Optimized connection pool from 10 to 4 maximum connections with memory-conscious settings.
- **Flask Configuration:** Added memory-efficient settings including disabled JSON sorting, optimized session handling, and file serving optimizations.
- **Dependency Addition:** Added gevent for more efficient async request handling compared to sync workers.
- **Expected Reduction:** RAM usage configurable from ~40MB (ultra-light) to ~200MB (performance) based on server specifications.
- _Files: `backend/gunicorn_config.py`, `backend/requirements.txt`, `backend/app.py`, `backend/db_handler.py`, `docker-compose.yml`_
### Changed
- **Environment Variables for OIDC (Docker):** While OIDC configuration is primarily managed via the database through the admin UI, the `docker-compose.yml` can include placeholder environment variables for initial setup or documentation:
- `OIDC_ENABLED` (e.g., `true` or `false`)
- `OIDC_PROVIDER_NAME` (e.g., `google`, `keycloak`)
- `OIDC_CLIENT_ID`
- `OIDC_CLIENT_SECRET`
- `OIDC_ISSUER_URL`
- `OIDC_SCOPE` (e.g., `openid email profile`)
- **`FRONTEND_URL` Environment Variable:** Emphasized the importance of correctly setting the `FRONTEND_URL` environment variable for the backend service in `docker-compose.yml` to ensure correct OIDC callback redirects.
- _Files: `docker-compose.yml`, `Docker/docker-compose.yml`_
### Fixed
- Resolved various startup and runtime errors related to OIDC client initialization in a multi-worker (Gunicorn) environment, particularly concerning database connection pool stability and consistent loading of OIDC settings.
- Corrected OIDC metadata fetching by ensuring the proper Issuer URL is used (e.g., `https://accounts.google.com` for Google).
- Resolved `TypeError: Cannot read properties of null (reading 'classList')` during logout on `about.html` by ensuring the `loadingContainer` element is present and accessible to `script.js`'s `showLoading()` function. Modified `showLoading()` and `hideLoading()` to dynamically find the container if not initially available.
- Fixed `SyntaxError: Identifier 'AuthManager' has already been declared` on `about.html` by removing a duplicate import of `auth.js` from the `<head>`, ensuring it is loaded only once at the end of the `<body>`.
- Standardized the user menu dropdown button ID to `userMenuBtn` across `index.html`, `status.html`, and `about.html`. Consolidated user menu JavaScript logic into `auth.js`, removing redundant code from `settings-new.js` and `fix-auth-buttons.js` to ensure consistent dropdown behavior.
- _Files: `backend/app.py`, `frontend/about.html`, `frontend/script.js`, `frontend/index.html`, `frontend/status.html`, `frontend/auth.js`, `frontend/settings-new.js`, `frontend/fix-auth-buttons.js`_
- **SSO Warranty Display Issue:** Fixed a critical timing problem where SSO users would see a blank warranty list after login. The issue was caused by a race condition where user preferences were loaded before user authentication was fully established.
- Enhanced `auth-redirect.html` to fetch and store user information immediately after SSO login, ensuring `user_info` is available when the main application loads.
- Fixed preference key prefix calculation to prevent mismatched user preference keys for SSO users.
- _Files: `frontend/auth-redirect.html`, `frontend/auth.js`, `frontend/script.js`_
- **SSO Tag Creation Issue:** Resolved an issue where SSO users could not create new tags due to authentication token handling problems.
- Updated tag creation functions (`createTag`, `updateTag`, `deleteTag`) to use `window.auth.getToken()` instead of directly accessing localStorage.
- Added enhanced error handling and debugging for tag creation API calls.
- Ensured consistent token management across all tag-related operations.
- _Files: `frontend/script.js`_
- **Database Tag Constraint Issue:** Fixed database constraint violation that prevented users from creating tags with names that already existed for other users.
- Created migration `024_fix_tags_constraint.sql` to update the database schema.
- Dropped the old `tags_name_key` unique constraint that only considered tag names.
- Added new `tags_name_user_id_key` constraint allowing the same tag name for different users.
- Added proper user_id foreign key constraint and performance index.
- _Files: `backend/migrations/024_fix_tags_constraint.sql`_
- **About Page User Menu Issue:** Fixed user menu dropdown not functioning on the about page for SSO users.
- Resolved script loading order conflicts that prevented user menu event listeners from being attached.
- Moved inline authentication check from head to body to prevent timing conflicts with `auth.js`.
- Added backup user menu handler specifically for the about page with enhanced debugging.
- Fixed JavaScript error caused by `getEventListeners` function not being available in all browsers.
- _Files: `frontend/about.html`, `frontend/auth.js`_
- **About Page Logout Issue:** Resolved logout functionality errors on the about page.
- Added safe loading spinner functions specifically for the about page to prevent `TypeError: Cannot read properties of null (reading 'classList')` error.
- Implemented backup logout handler with multiple fallback levels for reliability.
- Ensured logout works even when main auth system encounters errors.
- _Files: `frontend/about.html`, `frontend/script.js`_
- **Immediate Warranty Updates:** Optimized warranty editing for instant UI updates without server reloads.
- **Performance Improvement:** Warranty changes now appear immediately in the UI instead of waiting for server data reload.
- **Local Data Sync:** Edit modal updates local warranty data instantly upon successful save, eliminating loading delays.
- **Robust Date Handling:** Enhanced date arithmetic for duration-based warranties with proper month/year overflow handling.
- **Complete Field Updates:** All warranty fields (product info, dates, serial numbers, tags, documents) update immediately in the interface.
- **Fallback Safety:** Maintains fallback to server reload if local update fails, ensuring data consistency.
- _Files: `frontend/script.js`_
- **Warranty Entry Method Persistence:** Fixed warranty entry method selection not being remembered correctly in edit modal.
- **Issue:** When editing warranties that were created using "Exact Expiration Date" method, the edit modal would incorrectly switch to "Warranty Duration" method.
- **Root Cause:** Display logic was overwriting original duration values (0,0,0 for exact date method), making method detection impossible.
- **Solution:** Added `original_input_method` tracking to preserve the user's original warranty entry method choice.
- **Data Separation:** Implemented separate `display_duration_*` fields for warranty card display while preserving original duration values for method detection.
- **Enhanced Detection:** Edit modal now directly checks `original_input_method` field instead of guessing from duration data.
- **Result:** Users can now edit warranties and the modal correctly remembers whether they originally used "Warranty Duration" or "Exact Expiration Date" method.
- _Files: `frontend/script.js`_
- **Notes Modal and Edit Modal Integration:** Fixed issues with notes editing workflow and seamless transition to warranty editing.
- **Notes Editing for Exact Date Warranties:** Resolved issue where warranties created with exact expiration dates couldn't have their notes edited via the notes-link modal due to invalid duration validation.
- **Stale Notes in Edit Modal:** Fixed problem where editing a warranty immediately after saving notes via the notes modal would show old note content instead of the newly saved content.
- **Notes Modal UI Issues:** Fixed "Edit Notes" button showing outdated note content when clicked after saving new notes in the same modal session.
- **Enhanced Notes Modal:** Added "Edit Warranty" button directly in the notes modal footer for seamless transition to full warranty editing without modal layering conflicts.
- **Immediate Data Sync:** Enhanced notes saving to immediately update the global warranties array, ensuring edit modal always shows current data.
- **Modal State Management:** Improved modal closing behavior when opening edit modal from notes modal to prevent UI conflicts.
- _Files: `frontend/script.js`_
- **Status Page Edit Modal Consistency:** Fixed inconsistency between edit warranty modals on index.html and status.html pages.
- **Missing Warranty Entry Method Selection:** Added the "Warranty Entry Method" radio button selection to the status.html edit modal, allowing users to choose between "Warranty Duration" and "Exact Expiration Date" methods.
- **Missing Exact Expiration Field:** Added the hidden "Exact Expiration Date" input field that appears when the exact date method is selected.
- **Feature Parity:** The edit warranty modal on status.html now has identical functionality to the one on index.html, ensuring consistent user experience across both pages.
- **Complete Modal Structure:** All tabs (Product, Warranty, Documents, Tags), form fields, and functionality now match exactly between both pages.
- _Files: `frontend/status.html`_
### Dependencies
- **Updated Python Dependencies:** Resolved `pkg_resources` deprecation warning by updating backend dependencies to modern versions.
- **Gunicorn:** Updated from `20.1.0` to `23.0.0` - eliminates `pkg_resources` deprecation warning by using `importlib.metadata` instead.
- **Flask:** Updated from `2.0.1` to `3.0.3` - includes security patches and improved compatibility.
- **Werkzeug:** Updated from `2.0.1` to `3.0.3` - maintains compatibility with Flask 3.x.
- **Other Dependencies:** Updated `flask-cors`, `Flask-Login`, `PyJWT`, `email-validator`, `python-dateutil`, `Authlib`, `requests`, and `gevent` to latest stable versions.
- **Setuptools Protection:** Added `setuptools<81` pin to prevent future compatibility issues.
- _Files: `backend/requirements.txt`_
### Frontend
- **Chart.js Compatibility Fix:** Resolved "Chart is not defined" error on status page by replacing ES module version with UMD version.
- **Issue:** Status page charts failed to load with `ReferenceError: Chart is not defined` because the local `chart.js` file was in ES module format.
- **Root Cause:** ES modules require `<script type="module">` tags but the HTML was using regular `<script>` tags.
- **Solution:** Replaced ES module version with locally downloaded UMD (Universal Module Definition) version from Chart.js v4.4.9, eliminating CDN dependency.
- **Result:** Chart.js now loads properly with regular script tags and status page charts display correctly using the local file.
- _Files: `frontend/chart.js`_
- **Product Name Hover Tooltips:** Added hover tooltips to display full product names when they are truncated with ellipsis.
- **Enhancement:** Long product names that get cut off with "..." now show the complete product name in a tooltip when hovered over.
- **Coverage:** Works across all warranty display modes (grid view, list view, table view) on the main warranties page and the status page table.
- **Implementation:** Added `title` attributes to warranty titles and product name cells using escaped HTML for security.
- **Result:** Users can now see full product names without having to edit or expand warranty details.
- _Files: `frontend/script.js`, `frontend/status.js`_
- **Powered by Warracker Footer:** Added branded footer to all pages with dynamic theme support and GitHub repository link.
- **Feature:** Added "Powered by Warracker" footer to all application pages linking to the GitHub repository.
- **Theme Support:** Implemented JavaScript-based dynamic styling that automatically adapts to light/dark mode changes.
- **Light Mode:** Light gray background (`#f5f5f5`), dark text (`#333333`), blue links (`#3498db`) matching the logo.
- **Dark Mode:** Dark background (`#1a1a1a`), light text (`#e0e0e0`), light blue links (`#4dabf7`).
- **Real-time Updates:** Uses MutationObserver to detect theme changes and update footer styling automatically.
- **Cross-domain Compatibility:** Includes fallback inline styles and direct color values to ensure consistent display across different hosting environments.
- _Files: All frontend HTML pages, `frontend/style.css`_
## [0.9.9.8] - 2025-05-24
@@ -359,4 +519,4 @@
- **Layout:** Updated `.panel-header` CSS to use Flexbox for aligning the title (`h2`) to the left and action buttons (`.panel-header-actions` containing Add Warranty and Refresh buttons) to the right (`frontend/style.css`).
- **Layout:** Removed the `border-bottom` style from the `.panel-header` / `.warranties-panel h2` for a cleaner look (`frontend/style.css`).
- **Branding:** Updated the website `<title>` to include "Warracker" (`frontend/index.html`).
- **UI:** Added an "Add New Warranty" button (`#showAddWarrantyBtn`) to trigger the new modal (`frontend/index.html`
- **UI:** Added an "Add New Warranty" button (`#showAddWarrantyBtn`) to trigger the new modal (`frontend/index.html`

152
Docker/.env.example Normal file
View File

@@ -0,0 +1,152 @@
# Warracker Environment Variables Configuration
# Copy this file to .env and customize the values for your deployment
### ** Database Configuration**
# Database connection settings
DB_HOST=warrackerdb
DB_NAME=warranty_db
DB_USER=warranty_user
DB_PASSWORD=warranty_password
# Database admin credentials (used for migrations and setup)
DB_ADMIN_USER=warracker_admin
DB_ADMIN_PASSWORD=change_this_password_in_production
# PostgreSQL-specific settings (for the database container)
POSTGRES_DB=warranty_db
POSTGRES_USER=warranty_user
POSTGRES_PASSWORD=warranty_password
### Security Configuration**
# Application secret key for JWT tokens and Flask sessions
# IMPORTANT: Generate a strong, unique secret key for production!
SECRET_KEY=your_very_secret_flask_key_change_me
# JWT token expiration time (in hours)
JWT_EXPIRATION_HOURS=24
### Email/SMTP Configuration**
# SMTP server settings for sending notifications and password resets
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=youremail@gmail.com
SMTP_PASSWORD=your_email_password
# Optional SMTP settings
SMTP_USE_TLS=true
SMTP_USE_SSL=false
SMTP_SENDER_EMAIL=noreply@warracker.com
### **URL Configuration**
# Frontend URL (used for redirects and email links)
# IMPORTANT: Must match your public-facing URL for OIDC and email links to work
FRONTEND_URL=http://localhost:8005
# Application base URL (used for links in emails and redirects)
APP_BASE_URL=http://localhost:8005
### **File Upload Configuration**
# Maximum file upload size in megabytes
MAX_UPLOAD_MB=32
# Nginx maximum body size (should match or exceed MAX_UPLOAD_MB)
NGINX_MAX_BODY_SIZE_VALUE=32M
### **Performance & Memory Configuration**
# Memory optimization mode
# Options: optimized (default), ultra-light, performance
# - optimized: 2 workers, ~60-80MB RAM usage (recommended for most deployments)
# - ultra-light: 1 worker, ~40-50MB RAM usage (for very limited resources)
# - performance: 4 workers, ~150-200MB RAM usage (for high-traffic deployments)
WARRACKER_MEMORY_MODE=optimized
### **OIDC/SSO Configuration (Optional)**
# Enable/disable OIDC SSO functionality
OIDC_ENABLED=false
# OIDC Provider settings
# Provider name (affects button branding: google, github, microsoft, keycloak, etc.)
OIDC_PROVIDER_NAME=oidc
# OIDC client credentials (obtain from your OIDC provider)
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# OIDC issuer URL (e.g., https://accounts.google.com)
OIDC_ISSUER_URL=
# OIDC scope (space-separated list of scopes)
OIDC_SCOPE=openid email profile
### **Development/Debugging Configuration (Optional)**
# Flask environment (development/production)
FLASK_ENV=production
# Flask debug mode (true/false)
FLASK_DEBUG=false
# Flask run port (for development)
FLASK_RUN_PORT=5000
# Python unbuffered output (helpful for Docker logs)
PYTHONUNBUFFERED=1
### **Example Configurations**
**Gmail SMTP:**
```bash
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=youremail@gmail.com
SMTP_PASSWORD=your_app_password
SMTP_USE_TLS=true
```
**Google OIDC:**
```bash
OIDC_ENABLED=true
OIDC_PROVIDER_NAME=google
OIDC_CLIENT_ID=your_google_client_id.apps.googleusercontent.com
OIDC_CLIENT_SECRET=your_google_client_secret
OIDC_ISSUER_URL=https://accounts.google.com
OIDC_SCOPE=openid email profile
```
**Production deployment:**
```bash
SECRET_KEY=super_long_random_string_generated_securely
DB_PASSWORD=strong_database_password_123
DB_ADMIN_PASSWORD=different_strong_admin_password_456
FRONTEND_URL=https://warracker.yourdomain.com
APP_BASE_URL=https://warracker.yourdomain.com
SMTP_HOST=smtp.yourdomain.com
SMTP_USERNAME=warracker@yourdomain.com
MAX_UPLOAD_MB=64
NGINX_MAX_BODY_SIZE_VALUE=64M
WARRACKER_MEMORY_MODE=performance
```
## **How to Use**
1. **Copy this configuration** into a file named `.env` in your Docker folder
2. **Customize the values** according to your specific deployment needs
3. **Generate strong passwords** for production use, especially for `SECRET_KEY`, `DB_PASSWORD`, and `DB_ADMIN_PASSWORD`
4. **Set your domain URLs** correctly for `FRONTEND_URL` and `APP_BASE_URL` if deploying publicly
5. **Configure SMTP** if you want email functionality for password resets and notifications
6. **Set up OIDC/SSO** if you want single sign-on capabilities

View File

@@ -17,6 +17,19 @@ services:
- SECRET_KEY=${APP_SECRET_KEY:-your_strong_default_secret_key_here}
- MAX_UPLOAD_MB=32 # Example: Set max upload size to 32MB
- NGINX_MAX_BODY_SIZE_VALUE=32M # For Nginx, ensure this matches MAX_UPLOAD_MB in concept (e.g., 32M)
# OIDC SSO Configuration (User needs to set these based on their OIDC provider)
- OIDC_PROVIDER_NAME=${OIDC_PROVIDER_NAME:-oidc}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} # e.g., your_oidc_client_id_from_provider
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} # e.g., your_oidc_client_secret_from_provider
- OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-} # e.g., https://your-oidc-provider.com/auth/realms/your-realm
- OIDC_SCOPE=${OIDC_SCOPE:-openid email profile}
# URL settings (Important for OIDC redirects and email links)
# Ensure these point to the public-facing URL of your application
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:8005}
- APP_BASE_URL=${APP_BASE_URL:-http://localhost:8005}
# Memory optimization settings
- WARRACKER_MEMORY_MODE=${WARRACKER_MEMORY_MODE:-optimized} # Options: optimized (default), ultra-light
- PYTHONUNBUFFERED=1
# - FLASK_DEBUG=0
depends_on:
warrackerdb:
@@ -41,4 +54,4 @@ services:
volumes:
postgres_data:
warracker_uploads:
warracker_uploads:

View File

@@ -28,9 +28,19 @@ RUN pip install --no-cache-dir --upgrade pip
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code
# Copy main application and config files to /app
COPY backend/app.py .
COPY backend/gunicorn_config.py .
# Create the backend package directory in /app and copy modules into it
RUN mkdir -p /app/backend
COPY backend/__init__.py /app/backend/
COPY backend/auth_utils.py /app/backend/
COPY backend/db_handler.py /app/backend/
COPY backend/extensions.py /app/backend/
COPY backend/oidc_handler.py /app/backend/
# Copy other utility scripts and migrations
COPY backend/fix_permissions.py .
COPY backend/fix_permissions.sql .
COPY backend/migrations/ /app/migrations/
@@ -39,6 +49,7 @@ COPY backend/migrations/ /app/migrations/
COPY frontend/*.html /var/www/html/
COPY frontend/*.js /var/www/html/
COPY frontend/*.css /var/www/html/
COPY frontend/manifest.json /var/www/html/manifest.json
# Add favicon and images
COPY frontend/favicon.ico /var/www/html/
COPY frontend/img/ /var/www/html/img/

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# This file makes 'backend' a Python package

Binary file not shown.

File diff suppressed because it is too large Load Diff

19
backend/auth_utils.py Normal file
View File

@@ -0,0 +1,19 @@
# backend/auth_utils.py
import jwt
from datetime import datetime, timedelta
from flask import current_app # To access app.config
def generate_token(user_id):
"""Generate a JWT token for the user"""
payload = {
'exp': datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA'],
'iat': datetime.utcnow(),
'sub': user_id
}
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
# Note: You can later move decode_token, token_required, admin_required here too.
# For token_required and admin_required, you'll also need:
# from functools import wraps
# from flask import request, jsonify
# And you'd need to import get_db_connection, release_db_connection from your db_handler.

91
backend/db_handler.py Normal file
View File

@@ -0,0 +1,91 @@
# backend/db_handler.py
import os
import psycopg2
from psycopg2 import pool
import logging
import time
logger = logging.getLogger(__name__)
# PostgreSQL connection details
DB_HOST = os.environ.get('DB_HOST', 'warrackerdb')
DB_NAME = os.environ.get('DB_NAME', 'warranty_db')
DB_USER = os.environ.get('DB_USER', 'warranty_user')
DB_PASSWORD = os.environ.get('DB_PASSWORD', 'warranty_password')
connection_pool = None # Global connection pool for this module
def init_db_pool(max_retries=5, retry_delay=5):
global connection_pool # Ensure we're modifying the global variable in this module
attempt = 0
last_exception = None
if connection_pool is not None:
logger.info("[DB_HANDLER] Database connection pool already initialized.")
return connection_pool
while attempt < max_retries:
try:
logger.info(f"[DB_HANDLER] Attempting to initialize database pool (attempt {attempt+1}/{max_retries})")
# Optimized connection pool for memory efficiency
connection_pool = pool.SimpleConnectionPool(
1, 4, # Reduced from 1,10 to 1,4 for memory efficiency
host=DB_HOST,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
# Memory optimization settings
connect_timeout=10, # Connection timeout
application_name='warracker_optimized' # Identify connections
)
logger.info("[DB_HANDLER] Database connection pool initialized successfully.")
return connection_pool # Return the pool for external check if needed
except Exception as e:
last_exception = e
logger.error(f"[DB_HANDLER] Database connection pool initialization error: {e}")
logger.info(f"[DB_HANDLER] Retrying in {retry_delay} seconds...")
time.sleep(retry_delay)
attempt += 1
logger.error(f"[DB_HANDLER] Failed to initialize database pool after {max_retries} attempts.")
if last_exception:
raise last_exception
else:
raise Exception("Unknown error creating database pool")
def get_db_connection():
global connection_pool
if connection_pool is None:
logger.error("[DB_HANDLER] Database connection pool is None. Attempting to re-initialize.")
init_db_pool() # Attempt to initialize it
if connection_pool is None: # If still None after attempt
logger.critical("[DB_HANDLER] CRITICAL: Database pool re-initialization failed.")
raise Exception("Database connection pool is not initialized and could not be re-initialized.")
try:
return connection_pool.getconn()
except Exception as e:
logger.error(f"[DB_HANDLER] Error getting connection from pool: {e}")
raise
def release_db_connection(conn):
global connection_pool
if connection_pool:
try:
connection_pool.putconn(conn)
except Exception as e:
logger.error(f"[DB_HANDLER] Error releasing connection to pool: {e}. Connection state: {conn.closed if conn else 'N/A'}")
# If putconn fails, the connection might be broken or the pool is in a bad state.
# Attempt to close the connection directly as a fallback.
if conn and not conn.closed:
try:
conn.close()
logger.info("[DB_HANDLER] Connection closed directly after putconn failure.")
except Exception as close_err:
logger.error(f"[DB_HANDLER] Error closing connection directly after putconn failed: {close_err}")
else:
logger.warning("[DB_HANDLER] Connection pool is None, cannot release connection to pool. Attempting to close directly.")
if conn and not conn.closed:
try:
conn.close()
except Exception as e:
logger.error(f"[DB_HANDLER] Error closing connection directly (pool was None): {e}")

4
backend/extensions.py Normal file
View File

@@ -0,0 +1,4 @@
# backend/extensions.py
from authlib.integrations.flask_client import OAuth
oauth = OAuth()

View File

@@ -3,20 +3,58 @@
"""
Gunicorn configuration file for Warracker application.
This ensures scheduler only runs in one worker process.
Optimized for memory efficiency with configurable modes.
"""
import os
import multiprocessing
# Server configurations
# Server configurations - Dynamic based on memory mode
bind = "0.0.0.0:5000"
workers = 4
worker_class = "sync"
# Check memory mode from environment variable
memory_mode = os.environ.get('WARRACKER_MEMORY_MODE', 'optimized').lower()
if memory_mode == 'ultra-light':
# Ultra-lightweight configuration for very memory-constrained environments
workers = 1 # Single worker for minimal memory usage (~40-50MB total)
worker_class = "sync" # Sync worker for lowest memory overhead
worker_connections = 50 # Reduced connections
max_requests = 500 # More frequent worker restarts to prevent memory leaks
worker_rlimit_as = 67108864 # 64MB per worker limit
print("Using ULTRA-LIGHT memory mode - minimal RAM usage, lower concurrency")
elif memory_mode == 'performance':
# High-performance configuration for servers with plenty of RAM
workers = 4 # Original worker count for maximum concurrency
worker_class = "gevent" # Efficient async I/O handling
worker_connections = 200 # Higher connection limit per worker
max_requests = 2000 # Less frequent restarts for better performance
worker_rlimit_as = 268435456 # 256MB per worker limit
print("Using PERFORMANCE memory mode - maximum concurrency and performance")
else:
# Default optimized configuration for balanced performance and memory usage
workers = 2 # Reduced from 4 to save ~75MB RAM
worker_class = "gevent" # More memory efficient than sync workers
worker_connections = 100 # Limit concurrent connections per worker
max_requests = 1000 # Restart workers after handling requests to prevent memory leaks
worker_rlimit_as = 134217728 # 128MB per worker limit
print("Using OPTIMIZED memory mode - balanced RAM usage and performance")
# Common settings for both modes
timeout = 120
keepalive = 5
max_requests_jitter = 50 # Add randomness to prevent thundering herd
# Set worker environment variables
# Enhanced settings for file handling to prevent Content-Length mismatches
limit_request_line = 8190 # Increase request line limit
limit_request_fields = 200 # Increase header fields limit
limit_request_field_size = 8190 # Increase header field size limit
# Memory management (common to both modes)
preload_app = True # Share memory between workers (saves RAM)
worker_tmp_dir = "/dev/shm" # Use RAM disk for worker temporary files
# Process management callbacks
def worker_int(worker):
"""Called just after a worker exited on SIGINT or SIGQUIT."""
print(f"Worker {worker.pid} received SIGINT/SIGQUIT")
@@ -31,18 +69,24 @@ def worker_exit(server, worker):
def on_starting(server):
"""Called just before the master process is initialized."""
print("Server is starting")
print("Server is starting with memory-optimized configuration")
def post_fork(server, worker):
"""Called just after a worker has been forked."""
os.environ["GUNICORN_WORKER_ID"] = str(worker.age - 1) # Worker ID starts at 0
os.environ["GUNICORN_WORKER_ID"] = str(worker.age - 1)
os.environ["GUNICORN_WORKER_PROCESS_NAME"] = f"worker-{worker.age - 1}"
os.environ["GUNICORN_WORKER_CLASS"] = worker_class
print(f"Worker {worker.pid} (ID: {worker.age - 1}) forked")
print(f"Worker {worker.pid} (ID: {worker.age - 1}) forked with memory optimization")
def pre_fork(server, worker):
"""Called just before a worker is forked."""
print(f"Forking worker #{worker.age}")
print(f"Gunicorn configuration loaded with {workers} workers")
print(f"Gunicorn configuration loaded: {workers} {worker_class} workers in {memory_mode.upper()} mode")
print(f"Memory limit per worker: {worker_rlimit_as // 1024 // 1024}MB, Max connections: {worker_connections if 'worker_connections' in locals() else 'N/A'}")
# To switch memory modes, set WARRACKER_MEMORY_MODE environment variable:
# - "optimized" (default): 2 gevent workers, balanced performance and memory usage (~60-80MB)
# - "ultra-light": 1 sync worker, minimal memory usage (~40-50MB, lower concurrency)
# - "performance": 4 gevent workers, high-performance mode (~200MB)

View File

@@ -0,0 +1,31 @@
-- Add oidc_sub and oidc_issuer columns to the users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS oidc_sub VARCHAR(255),
ADD COLUMN IF NOT EXISTS oidc_issuer VARCHAR(255);
-- Make password_hash nullable for OIDC-only users
-- This assumes the column 'password_hash' exists.
-- If it might not, the original DO $$ block with IF EXISTS for the column is safer.
-- However, given it's a core user attribute, it should exist.
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
-- Add a unique constraint for oidc_sub and oidc_issuer.
-- This will fail if the constraint already exists, which is acceptable as the migration runner
-- should catch the error and skip/log if the migration was already partially applied.
-- A more robust way is to check information_schema, but we're simplifying due to `RAISE` issues.
ALTER TABLE users ADD CONSTRAINT uq_users_oidc_sub_issuer UNIQUE (oidc_sub, oidc_issuer);
-- Add login_method to user_sessions table
ALTER TABLE user_sessions
ADD COLUMN IF NOT EXISTS login_method VARCHAR(50) DEFAULT 'local';
-- Update existing sessions to 'local' if login_method is NULL
UPDATE user_sessions
SET login_method = 'local'
WHERE login_method IS NULL;
-- Make login_method not nullable after updating existing rows
-- This assumes the column 'login_method' now exists.
ALTER TABLE user_sessions ALTER COLUMN login_method SET NOT NULL;
-- End of migration

View File

@@ -0,0 +1,52 @@
-- Fix tags table constraints to allow per-user tag names
DO $$
BEGIN
-- Check if user_id column exists, if not add it
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tags' AND column_name = 'user_id'
) THEN
-- Add user_id column as nullable first
ALTER TABLE tags ADD COLUMN user_id INTEGER;
-- Update existing tags to have user_id = 1 (assuming admin user)
UPDATE tags SET user_id = 1 WHERE user_id IS NULL;
-- Make the column NOT NULL
ALTER TABLE tags ALTER COLUMN user_id SET NOT NULL;
-- Add foreign key constraint
ALTER TABLE tags ADD CONSTRAINT fk_tags_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
END IF;
-- Drop the old unique constraint on name only (if it exists)
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'tags' AND constraint_name = 'tags_name_key'
) THEN
ALTER TABLE tags DROP CONSTRAINT tags_name_key;
RAISE NOTICE 'Dropped old tags_name_key constraint';
END IF;
-- Add new unique constraint on name and user_id (if it doesn't exist)
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'tags' AND constraint_name = 'tags_name_user_id_key'
) THEN
ALTER TABLE tags ADD CONSTRAINT tags_name_user_id_key
UNIQUE (name, user_id);
RAISE NOTICE 'Added new tags_name_user_id_key constraint';
END IF;
-- Create index for faster lookups (if it doesn't exist)
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'tags' AND indexname = 'idx_tags_user_id'
) THEN
CREATE INDEX idx_tags_user_id ON tags (user_id);
RAISE NOTICE 'Created idx_tags_user_id index';
END IF;
RAISE NOTICE 'Tags table constraint fix completed successfully';
END $$;

253
backend/oidc_handler.py Normal file
View File

@@ -0,0 +1,253 @@
# backend/oidc_handler.py
import os
import uuid
from datetime import datetime # Ensure timedelta is imported if used, though not in this snippet
from flask import Blueprint, jsonify, redirect, url_for, current_app, request, session
# Import shared extensions and utilities
from backend.extensions import oauth
from backend.db_handler import get_db_connection, release_db_connection
from backend.auth_utils import generate_token
import logging
logger = logging.getLogger(__name__) # Or use current_app.logger inside routes
oidc_bp = Blueprint('oidc', __name__) # url_prefix will be set when registering in app.py
@oidc_bp.route('/oidc/login') # Original path was /api/oidc/login
def oidc_login_route():
if not current_app.config.get('OIDC_ENABLED'):
logger.warning("[OIDC_HANDLER] OIDC login attempt while OIDC is disabled.")
return jsonify({'message': 'OIDC (SSO) login is not enabled.'}), 403
oidc_provider_name = current_app.config.get('OIDC_PROVIDER_NAME')
if not oidc_provider_name:
logger.error("[OIDC_HANDLER] OIDC is enabled but provider name not configured.")
return jsonify({'message': 'OIDC provider not configured correctly.'}), 500
# Corrected url_for to use blueprint name
redirect_uri = url_for('oidc.oidc_callback_route', _external=True)
# HTTPS check for production
if os.environ.get('FLASK_ENV') == 'production' and not redirect_uri.startswith('https'):
redirect_uri = redirect_uri.replace('http://', 'https://', 1)
logger.info(f"[OIDC_HANDLER] /oidc/login redirect_uri: {redirect_uri}")
return oauth.create_client(oidc_provider_name).authorize_redirect(redirect_uri)
@oidc_bp.route('/oidc/callback') # Original path was /api/oidc/callback
def oidc_callback_route():
if not current_app.config.get('OIDC_ENABLED'):
logger.warning("[OIDC_HANDLER] OIDC callback received while OIDC is disabled.")
frontend_login_url = os.environ.get('FRONTEND_URL', current_app.config.get('APP_BASE_URL', 'http://localhost:8080')).rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=oidc_disabled")
oidc_provider_name = current_app.config.get('OIDC_PROVIDER_NAME')
if not oidc_provider_name:
logger.error("[OIDC_HANDLER] OIDC provider name not configured for callback.")
frontend_login_url = os.environ.get('FRONTEND_URL', current_app.config.get('APP_BASE_URL', 'http://localhost:8080')).rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=oidc_misconfigured")
client = oauth.create_client(oidc_provider_name)
try:
token_data = client.authorize_access_token()
except Exception as e:
logger.error(f"[OIDC_HANDLER] OIDC callback error authorizing access token: {e}")
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=token_exchange_failed")
if not token_data:
logger.error("[OIDC_HANDLER] OIDC callback: Failed to retrieve access token.")
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=token_missing")
userinfo = token_data.get('userinfo')
if not userinfo:
try:
userinfo = client.userinfo(token=token_data)
except Exception as e:
logger.error(f"[OIDC_HANDLER] OIDC callback error fetching userinfo: {e}")
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=userinfo_fetch_failed")
if not userinfo:
logger.error("[OIDC_HANDLER] OIDC callback: Failed to retrieve userinfo.")
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=userinfo_missing")
oidc_subject = userinfo.get('sub')
oidc_issuer = userinfo.get('iss')
if not oidc_subject:
logger.error("[OIDC_HANDLER] OIDC callback: 'sub' (subject) missing in userinfo.")
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=subject_missing")
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
# Check for existing OIDC user
cur.execute("SELECT id, username, email, is_admin FROM users WHERE oidc_sub = %s AND oidc_issuer = %s AND is_active = TRUE",
(oidc_subject, oidc_issuer))
user_db_data = cur.fetchone()
user_id = None
is_new_user = False
if user_db_data:
user_id = user_db_data[0]
logger.info(f"[OIDC_HANDLER] Existing OIDC user found with ID {user_id} for sub {oidc_subject}")
else:
# Check if registration is enabled before creating new users
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'site_settings'
)
""")
table_exists = cur.fetchone()[0]
registration_enabled = True
if table_exists:
# Get registration_enabled setting
cur.execute("SELECT value FROM site_settings WHERE key = 'registration_enabled'")
result = cur.fetchone()
if result:
registration_enabled = result[0].lower() == 'true'
# Check if there are any users (first user can register regardless of setting)
cur.execute('SELECT COUNT(*) FROM users')
user_count = cur.fetchone()[0]
# If registration is disabled and this is not the first user, deny SSO signup
if not registration_enabled and user_count > 0:
logger.warning(f"[OIDC_HANDLER] New OIDC user registration denied - registrations are disabled. Subject: {oidc_subject}")
frontend_login_url = os.environ.get('FRONTEND_URL', current_app.config.get('APP_BASE_URL', 'http://localhost:8080')).rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=registration_disabled")
# New user provisioning
is_new_user = True
email = userinfo.get('email')
if not email:
logger.error("[OIDC_HANDLER] 'email' missing in userinfo for new OIDC user.")
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=email_missing_for_new_user")
# Check for email conflict with local account
cur.execute("SELECT id FROM users WHERE email = %s AND (oidc_sub IS NULL OR oidc_issuer IS NULL)", (email,))
if cur.fetchone():
logger.warning(f"[OIDC_HANDLER] Email {email} already exists for a local account. OIDC user cannot be created.")
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=email_conflict_local_account")
username = userinfo.get('preferred_username') or userinfo.get('name') or email.split('@')[0]
# Ensure username uniqueness
cur.execute("SELECT id FROM users WHERE username = %s", (username,))
if cur.fetchone():
username = f"{username}_{str(uuid.uuid4())[:4]}" # Short random suffix
first_name = userinfo.get('given_name', '')
last_name = userinfo.get('family_name', '')
cur.execute('SELECT COUNT(*) FROM users')
user_count = cur.fetchone()[0]
# Determine admin status: first user OR email matches configured admin email
is_first_user_admin = (user_count == 0)
admin_email_from_env = current_app.config.get('ADMIN_EMAIL', '').lower()
oidc_user_email_lower = email.lower() if email else ''
is_email_match_admin = False
if admin_email_from_env and oidc_user_email_lower == admin_email_from_env:
is_email_match_admin = True
logger.info(f"[OIDC_HANDLER] New OIDC user email {oidc_user_email_lower} matches ADMIN_EMAIL {admin_email_from_env}.")
is_admin = is_first_user_admin or is_email_match_admin
if is_admin and not is_first_user_admin:
logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} based on email match.")
elif is_first_user_admin:
logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} as they are the first user.")
# Insert new OIDC user
cur.execute(
"""INSERT INTO users (username, email, first_name, last_name, is_admin, oidc_sub, oidc_issuer, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, TRUE) RETURNING id""",
(username, email, first_name, last_name, is_admin, oidc_subject, oidc_issuer)
)
user_id = cur.fetchone()[0]
logger.info(f"[OIDC_HANDLER] New OIDC user created with ID {user_id} for sub {oidc_subject}")
if user_id:
app_session_token = generate_token(user_id) # Generate app-specific JWT
# Update last login timestamp
cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.utcnow(), user_id))
# Log OIDC session in user_sessions table
ip_address = request.remote_addr
user_agent = request.headers.get('User-Agent', '')
# Use a different UUID for session_token in DB if needed, or re-use app_session_token if appropriate for your session model
db_session_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA']
cur.execute(
'INSERT INTO user_sessions (user_id, session_token, expires_at, ip_address, user_agent, login_method) VALUES (%s, %s, %s, %s, %s, %s)',
(user_id, db_session_token, expires_at, ip_address, user_agent, 'oidc')
)
conn.commit()
frontend_url = os.environ.get('FRONTEND_URL', current_app.config.get('APP_BASE_URL', 'http://localhost:8080')).rstrip('/')
redirect_target = f"{frontend_url}/auth-redirect.html?token={app_session_token}"
if is_new_user:
redirect_target += "&new_user=true"
logger.info(f"[OIDC_HANDLER] /oidc/callback redirecting to frontend: {redirect_target}")
return redirect(redirect_target)
else:
logger.error("[OIDC_HANDLER] /oidc/callback User ID not established after DB ops.")
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=user_processing_failed")
except Exception as e: # Catch more specific psycopg2.Error if preferred
logger.error(f"[OIDC_HANDLER] OIDC callback: Database or general error: {e}", exc_info=True)
if conn: conn.rollback()
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
return redirect(f"{frontend_login_url}?oidc_error=internal_error")
finally:
if conn: release_db_connection(conn)
@oidc_bp.route('/auth/oidc-status', methods=['GET']) # Path relative to blueprint's url_prefix
def get_oidc_status_route():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT value FROM site_settings WHERE key = 'oidc_enabled'")
result = cur.fetchone()
oidc_is_enabled = False
if result and result[0] is not None:
oidc_is_enabled = str(result[0]).lower() == 'true'
cur.execute("SELECT value FROM site_settings WHERE key = 'oidc_provider_name'")
provider_name_result = cur.fetchone()
oidc_provider_name = 'SSO Provider' # Default button text
if provider_name_result and provider_name_result[0]:
raw_name = provider_name_result[0]
# Simple capitalization for display
oidc_provider_name = raw_name.capitalize() if raw_name else 'SSO Provider'
return jsonify({
"oidc_enabled": oidc_is_enabled,
"oidc_provider_display_name": oidc_provider_name
}), 200
except Exception as e:
logger.error(f"[OIDC_HANDLER] Error fetching OIDC status: {e}")
return jsonify({"oidc_enabled": False, "oidc_provider_display_name": "SSO Provider"}), 200 # Default to false on error
finally:
if conn:
release_db_connection(conn)

View File

@@ -1,11 +1,15 @@
Flask==2.0.1
gunicorn==20.1.0
psycopg2-binary==2.9.3
Werkzeug==2.0.1
flask-cors==3.0.10
Flask-Login==0.6.2
Flask==3.0.3
gunicorn==23.0.0
psycopg2-binary==2.9.9
Werkzeug==3.0.3
flask-cors==4.0.1
Flask-Login==0.6.3
Flask-Bcrypt==1.0.1
PyJWT==2.6.0
email-validator==1.3.1
PyJWT==2.8.0
email-validator==2.1.1
APScheduler==3.10.4
python-dateutil
python-dateutil==2.9.0
Authlib==1.3.1
requests==2.32.3
gevent==24.2.1
setuptools<81

View File

@@ -19,7 +19,21 @@ services:
- SMTP_PORT=${SMTP_PORT:-1025}
- SMTP_USERNAME=${SMTP_USERNAME:-notifications@warracker.com}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SECRET_KEY=${SECRET_KEY:-your_very_secret_flask_key_change_me} # For Flask session and JWT
# OIDC SSO Configuration (User needs to set these based on their OIDC provider)
- OIDC_PROVIDER_NAME=${OIDC_PROVIDER_NAME:-oidc}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} # e.g., your_oidc_client_id
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} # e.g., your_oidc_client_secret
- OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-} # e.g., https://your-oidc-provider.com/auth/realms/your-realm
- OIDC_SCOPE=${OIDC_SCOPE:-openid email profile}
# URL settings (Important for redirects and email links)
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:8005} # Public URL of the frontend (matching the port mapping)
- APP_BASE_URL=${APP_BASE_URL:-http://localhost:8005} # Public base URL of the application for links
- PYTHONUNBUFFERED=1
# Memory optimization settings
- WARRACKER_MEMORY_MODE=${WARRACKER_MEMORY_MODE:-optimized} # Options: optimized (default), ultra-light, performance
- MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-16} # Reduced from 32MB default for memory efficiency
- NGINX_MAX_BODY_SIZE_VALUE=${NGINX_MAX_BODY_SIZE_VALUE:-16M} # Match upload limit
depends_on:
warrackerdb:
condition: service_healthy

View File

@@ -25,22 +25,6 @@
<script src="theme-loader.js"></script> <!-- Apply theme early -->
<script src="include-auth-new.js"></script> <!-- Handles auth state display -->
<script src="fix-auth-buttons-loader.js"></script> <!-- Fixes auth button display timing -->
<script src="auth.js"></script> <!-- Load auth.js early for authentication check -->
<!-- Authentication Check -->
<script>
document.addEventListener('DOMContentLoaded', () => {
// Check if user is authenticated
const authToken = localStorage.getItem('auth_token');
const userInfo = localStorage.getItem('user_info');
if (!authToken || !userInfo) {
// User is not authenticated, redirect to login page
window.location.href = 'login.html';
return;
}
});
</script>
</head>
<body>
@@ -71,7 +55,7 @@
</a>
</div>
<div id="userMenu" class="user-menu" style="display: none;">
<button id="userBtn" class="user-btn">
<button id="userMenuBtn" class="user-btn">
<i class="fas fa-user-circle"></i>
<span id="userDisplayName">User</span>
</button>
@@ -106,7 +90,7 @@
<div class="about-container" style="background-color: var(--card-bg); color: var(--text-color); padding: 20px; border-radius: 8px; margin-top: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); width: 100%; box-sizing: border-box;">
<h1 style="color: var(--primary-color); margin-bottom: 20px;">About Warracker</h1>
<p><strong>Version:</strong> v0.9.9.8</p>
<p><strong>Version:</strong> v0.9.9.9</p>
<div id="versionTracker" style="margin-bottom: 20px;">
<p><strong>Update Status:</strong> <span id="updateStatus">Checking for updates...</span></p>
@@ -160,20 +144,224 @@
</div>
<!-- Scripts loaded at the end of body -->
<script src="auth.js"></script> <!-- Added for user menu and other auth interactions -->
<script src="auth.js?v=2"></script> <!-- Added for user menu and other auth interactions -->
<script src="script.js"></script> <!-- Include main site script, might contain helpers -->
<script src="version-checker.js"></script> <!-- Version checker script -->
<!-- Explicitly call setupUIEventListeners for this page -->
<script>
document.addEventListener('DOMContentLoaded', () => {
console.log('About page: DOMContentLoaded triggered');
// Add safe loading functions for about page
window.showLoading = function() {
const loadingContainer = document.getElementById('loadingContainer');
if (loadingContainer && loadingContainer.classList) {
loadingContainer.classList.add('active');
console.log('About page: Loading spinner shown');
} else {
console.log('About page: Loading container not found or unavailable');
}
};
window.hideLoading = function() {
const loadingContainer = document.getElementById('loadingContainer');
if (loadingContainer && loadingContainer.classList) {
loadingContainer.classList.remove('active');
console.log('About page: Loading spinner hidden');
} else {
console.log('About page: Loading container not found or unavailable');
}
};
// Keep theme initialization here if needed for specific timing
if (typeof initializeTheme === 'function') {
console.log('About page: Calling initializeTheme');
initializeTheme(); // Call theme initialization if it exists
}
// Add specific debugging for user menu elements
setTimeout(() => {
const userMenuBtn = document.getElementById('userMenuBtn');
const userMenuDropdown = document.getElementById('userMenuDropdown');
console.log('About page: User menu debug info:', {
userMenuBtn: !!userMenuBtn,
userMenuDropdown: !!userMenuDropdown,
userMenuBtnId: userMenuBtn ? userMenuBtn.id : 'not found',
dropdownId: userMenuDropdown ? userMenuDropdown.id : 'not found',
authModuleAvailable: !!window.auth,
isAuthenticated: window.auth ? window.auth.isAuthenticated() : 'auth not available'
});
if (userMenuBtn) {
console.log('About page: userMenuBtn element:', userMenuBtn);
console.log('About page: userMenuBtn element found, proceeding with backup handler setup');
}
// Add a backup user menu handler specifically for about page
if (userMenuBtn && userMenuDropdown) {
console.log('About page: Setting up backup user menu handler');
console.log('About page: Current dropdown classes:', userMenuDropdown.className);
console.log('About page: Current dropdown active state:', userMenuDropdown.classList.contains('active'));
// Test CSS rules by temporarily adding/removing active class
console.log('About page: Testing CSS rules...');
userMenuDropdown.classList.add('active');
const testDisplayActive = window.getComputedStyle(userMenuDropdown).display;
userMenuDropdown.classList.remove('active');
const testDisplayInactive = window.getComputedStyle(userMenuDropdown).display;
console.log('About page: CSS test results:', {
displayWhenActive: testDisplayActive,
displayWhenInactive: testDisplayInactive
});
// Remove any existing click listeners and add our own
const newUserMenuBtn = userMenuBtn.cloneNode(true);
userMenuBtn.parentNode.replaceChild(newUserMenuBtn, userMenuBtn);
newUserMenuBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log('About page: Backup user menu clicked');
console.log('About page: Dropdown before toggle:', {
classes: userMenuDropdown.className,
hasActive: userMenuDropdown.classList.contains('active'),
style: userMenuDropdown.style.display
});
const isCurrentlyActive = userMenuDropdown.classList.contains('active');
if (isCurrentlyActive) {
userMenuDropdown.classList.remove('active');
console.log('About page: User menu closed');
} else {
userMenuDropdown.classList.add('active');
console.log('About page: User menu opened');
}
// Double-check the state after toggle
console.log('About page: Dropdown after toggle:', {
classes: userMenuDropdown.className,
hasActive: userMenuDropdown.classList.contains('active'),
computedStyle: window.getComputedStyle(userMenuDropdown).display
});
});
// Add global click listener to close menu when clicking outside
const outsideClickHandler = (e) => {
if (userMenuDropdown.classList.contains('active') &&
!userMenuDropdown.contains(e.target) &&
!newUserMenuBtn.contains(e.target)) {
userMenuDropdown.classList.remove('active');
console.log('About page: User menu closed by outside click');
}
};
// Use setTimeout to ensure this listener is added after any others
setTimeout(() => {
document.addEventListener('click', outsideClickHandler);
console.log('About page: Outside click handler added');
}, 10);
console.log('About page: Backup user menu handler set up successfully');
// Add specific logout handler for the about page
const logoutMenuItem = document.getElementById('logoutMenuItem');
if (logoutMenuItem) {
// Clone the logout menu item to remove any existing listeners
const newLogoutMenuItem = logoutMenuItem.cloneNode(true);
logoutMenuItem.parentNode.replaceChild(newLogoutMenuItem, logoutMenuItem);
newLogoutMenuItem.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
console.log('About page: Logout clicked (backup handler)');
// Close the user menu
userMenuDropdown.classList.remove('active');
try {
// Clear auth data manually since showLoading might fail
localStorage.removeItem('auth_token');
localStorage.removeItem('user_info');
// Try to call the auth manager logout if available
if (window.auth && typeof window.auth.logout === 'function') {
console.log('About page: Calling auth.logout()');
await window.auth.logout();
} else {
console.log('About page: Auth manager not available, redirecting manually');
// Redirect manually if auth manager fails
window.location.href = 'login.html';
}
} catch (error) {
console.error('About page: Logout error, falling back to manual redirect:', error);
// Clear auth data and redirect as fallback
localStorage.removeItem('auth_token');
localStorage.removeItem('user_info');
window.location.href = 'login.html';
}
});
console.log('About page: Backup logout handler set up');
}
} else {
console.error('About page: User menu elements not found!', {
userMenuBtn: !!userMenuBtn,
userMenuDropdown: !!userMenuDropdown
});
}
}, 200); // Delay to allow auth.js to complete setup
// Move authentication check here, after auth.js has had time to set up
setTimeout(() => {
// Check if user is authenticated (with a small delay to allow auth.js to process)
const authToken = localStorage.getItem('auth_token');
const userInfo = localStorage.getItem('user_info');
if (!authToken || !userInfo) {
// User is not authenticated, redirect to login page
console.log('About page: User not authenticated, redirecting to login');
window.location.href = 'login.html';
return;
}
}, 100); // Small delay to allow auth.js to complete setup
});
</script>
<!-- Loading Spinner -->
<div class="loading-container" id="loadingContainer">
<div class="loading-spinner"></div>
</div>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>

190
frontend/auth-redirect.html Normal file
View File

@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authenticating...</title>
<link rel="stylesheet" href="style.css">
<script src="theme-loader.js"></script>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: var(--bg-color);
color: var(--text-color);
font-family: sans-serif;
}
.container {
text-align: center;
padding: 20px;
background-color: var(--card-bg);
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.error-message {
color: #f44336; /* Red for errors */
margin-bottom: 15px;
}
a {
color: var(--primary-color);
}
</style>
</head>
<body>
<div class="container">
<h2 id="statusMessage">Authenticating, please wait...</h2>
<div id="errorMessage" class="error-message" style="display: none;"></div>
<a href="login.html" id="loginLink" style="display: none;">Return to Login</a>
</div>
<script>
async function fetchUserInfoAndRedirect(token, isNewUser) {
const statusMessage = document.getElementById('statusMessage');
const errorMessageEl = document.getElementById('errorMessage');
const loginLink = document.getElementById('loginLink');
try {
statusMessage.textContent = 'Fetching user information...';
// Fetch user info using the token
const response = await fetch('/api/auth/validate-token', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
if (data.valid && data.user && data.user.id) {
// Store user info in localStorage
localStorage.setItem('user_info', JSON.stringify(data.user));
// Update status message
if (isNewUser) {
statusMessage.textContent = 'Account created successfully! Redirecting...';
} else {
statusMessage.textContent = 'Login successful! Redirecting...';
}
// Redirect to the main application page
setTimeout(() => {
window.location.href = 'index.html';
}, 1500);
} else {
throw new Error('Invalid user data received');
}
} else {
throw new Error(`Failed to validate token: ${response.status}`);
}
} catch (error) {
console.error('Error fetching user info:', error);
// Clear potentially invalid token
localStorage.removeItem('auth_token');
localStorage.removeItem('user_info');
statusMessage.textContent = 'SSO Login Failed';
errorMessageEl.textContent = 'Failed to fetch user information. Please try logging in again.';
errorMessageEl.style.display = 'block';
loginLink.style.display = 'inline';
}
}
document.addEventListener('DOMContentLoaded', function() {
const statusMessage = document.getElementById('statusMessage');
const errorMessageEl = document.getElementById('errorMessage');
const loginLink = document.getElementById('loginLink');
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const oidcError = params.get('oidc_error');
const newUser = params.get('new_user');
if (oidcError) {
let message = 'An unknown error occurred during SSO login.';
switch (oidcError) {
case 'token_exchange_failed':
message = 'Failed to exchange authorization code for tokens with the SSO provider.';
break;
case 'token_missing':
message = 'Access token was not received from the SSO provider.';
break;
case 'userinfo_fetch_failed':
message = 'Failed to fetch user information from the SSO provider.';
break;
case 'userinfo_missing':
message = 'User information was not received from the SSO provider.';
break;
case 'subject_missing':
message = 'User subject identifier (sub) was missing from SSO provider response.';
break;
case 'email_missing_for_new_user':
message = 'Email address was not provided by SSO provider, which is required for new user registration.';
break;
case 'email_conflict_local_account':
message = 'The email address from your SSO provider is already associated with an existing local account. Please log in with your local credentials or contact support.';
break;
case 'registration_disabled':
message = 'New user registration via SSO is currently disabled. Only existing users can log in with SSO. Please contact your administrator if you need an account.';
break;
case 'user_processing_failed':
message = 'Failed to process user information after SSO login.';
break;
case 'db_error':
message = 'A database error occurred during SSO login. Please try again later.';
break;
case 'internal_error':
message = 'An internal server error occurred during SSO login. Please try again later.';
break;
}
statusMessage.textContent = 'SSO Login Failed';
errorMessageEl.textContent = message;
errorMessageEl.style.display = 'block';
loginLink.style.display = 'inline';
} else if (token) {
localStorage.setItem('auth_token', token);
// Fetch user info immediately after storing token to ensure it's available
// when the main app loads, preventing preference key mismatches
fetchUserInfoAndRedirect(token, newUser === 'true');
} else {
// No token and no error, unexpected state, redirect to login
statusMessage.textContent = 'Redirecting to login...';
setTimeout(() => {
window.location.href = 'login.html';
}, 1000);
}
});
</script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>

View File

@@ -3,385 +3,362 @@
* Handles user login, logout, and authentication state management
*/
// DOM Elements
const authContainer = document.getElementById('authContainer');
const userMenu = document.getElementById('userMenu');
const userBtn = document.getElementById('userBtn');
const userMenuDropdown = document.getElementById('userMenuDropdown');
const userDisplayName = document.getElementById('userDisplayName');
const userName = document.getElementById('userName');
const userEmail = document.getElementById('userEmail');
const logoutMenuItem = document.getElementById('logoutMenuItem');
const profileMenuItem = document.getElementById('profileMenuItem');
class AuthManager {
constructor() {
this.token = null;
this.currentUser = null;
this.onLogoutCallbacks = [];
// New UI Elements (from screenshot)
const loginButton = document.querySelector('a[href="login.html"]');
const registerButton = document.querySelector('a[href="register.html"]');
const usernameDisplay = document.querySelector('.user-name');
// Authentication state
let currentUser = null;
let authToken = null;
// Initialize authentication
document.addEventListener('DOMContentLoaded', () => {
// Initial check of authentication state
checkAuthState();
// Set up periodic check of auth state (every 30 seconds)
// setInterval(checkAuthState, 30000); // Consider if needed, can cause flicker
// --- USER MENU TOGGLE ---
const userMenuBtn = document.getElementById('userMenuBtn');
const userMenuDropdown = document.getElementById('userMenuDropdown');
console.log('auth.js: Checking for user menu elements...', { userMenuBtn, userMenuDropdown }); // Debug log
if (userMenuBtn && userMenuDropdown) {
console.log('auth.js: User menu elements found, adding listener.'); // Debug log
userMenuBtn.addEventListener('click', (e) => {
console.log('auth.js: User menu button CLICKED!'); // *** ADDED LOG ***
e.stopPropagation();
userMenuDropdown.classList.toggle('active');
console.log('auth.js: User menu dropdown toggled.', { active: userMenuDropdown.classList.contains('active') }); // *** ADDED LOG ***
});
} else {
console.log('auth.js: User menu button or dropdown not found on this page.'); // Debug log
}
// --- SETTINGS GEAR MENU TOGGLE (Moved from settings-new.js) ---
const settingsBtn = document.getElementById('settingsBtn'); // Gear icon button
const settingsMenu = document.getElementById('settingsMenu'); // The dropdown menu itself
console.log('auth.js: Checking for settings menu elements...', { settingsBtn, settingsMenu }); // Debug log
if (settingsBtn && settingsMenu) {
console.log('auth.js: Settings menu elements found, adding listeners.'); // Debug log
// Toggle settings menu when settings button is clicked
settingsBtn.addEventListener('click', function(e) {
e.stopPropagation(); // Prevent click from closing menu immediately
settingsMenu.classList.toggle('active');
console.log('auth.js: Settings button clicked, menu toggled.', { active: settingsMenu.classList.contains('active') }); // Debug log
});
} else {
console.log('auth.js: Settings button or menu not found on this page.'); // Debug log
}
// --- END SETTINGS GEAR MENU TOGGLE ---
// Close menus when clicking outside
document.addEventListener('click', (e) => {
// Close user menu
if (userMenuDropdown && userMenuBtn &&
userMenuDropdown.classList.contains('active') &&
!userMenuDropdown.contains(e.target) &&
!userMenuBtn.contains(e.target)) {
userMenuDropdown.classList.remove('active');
// Initial state load from localStorage
this.token = localStorage.getItem('auth_token');
const userInfoString = localStorage.getItem('user_info');
if (userInfoString) {
try {
this.currentUser = JSON.parse(userInfoString);
} catch (e) {
console.error('Auth.js: Corrupt user_info in localStorage. Clearing.');
this.currentUser = null;
localStorage.removeItem('user_info');
// Consider clearing token as well if user_info is corrupt
// localStorage.removeItem('auth_token');
// this.token = null;
}
}
// Close settings menu
if (settingsMenu && settingsBtn &&
settingsMenu.classList.contains('active') &&
!settingsMenu.contains(e.target) &&
!settingsBtn.contains(e.target)) {
settingsMenu.classList.remove('active');
console.log('auth.js: Click outside closed settings menu.'); // Debug log
}
});
// Logout functionality
const logoutMenuItem = document.getElementById('logoutMenuItem');
if (logoutMenuItem) {
logoutMenuItem.addEventListener('click', logout);
console.log('[Auth.js] Initial state:', { token: this.token ? 'present' : 'null', currentUser: this.currentUser });
}
isAuthenticated() {
// User is authenticated if both token and currentUser (with an id) are present
return !!(this.token && this.currentUser && this.currentUser.id);
}
getCurrentUser() {
return this.currentUser;
}
getToken() {
// Always return the current state of this.token, which should be synced with localStorage
return this.token;
}
clearAuthData() {
console.log('[Auth.js] Clearing auth data.');
localStorage.removeItem('auth_token');
localStorage.removeItem('user_info');
this.token = null;
this.currentUser = null;
this.onLogoutCallbacks.forEach(cb => cb());
}
// Profile menu item link (assuming it's an <a> tag now)
// const profileLink = document.querySelector('.user-menu-item a[href="settings-new.html"]');
// No special listener needed if it's just a link
});
onLogout(callback) {
if (typeof callback === 'function') {
this.onLogoutCallbacks.push(callback);
}
}
/**
* Check if user is authenticated and update UI accordingly
*/
function checkAuthState() {
// Get latest token from localStorage
authToken = localStorage.getItem('auth_token');
const userInfo = localStorage.getItem('user_info');
async checkAuthState(isInitialLoad = false) {
console.log('[Auth.js] checkAuthState called. Initial load:', isInitialLoad);
this.token = localStorage.getItem('auth_token'); // Re-read token, might have changed (e.g. by auth-redirect.js)
const userInfoString = localStorage.getItem('user_info');
this.currentUser = null; // Reset before check
if (userInfoString) {
try {
this.currentUser = JSON.parse(userInfoString);
} catch (e) {
console.error('Auth.js: Failed to parse user_info from localStorage during checkAuthState. Clearing auth data.', e);
this.clearAuthData(); // Clear potentially corrupt data
this.updateUIBasedOnAuthState(); // Update UI to reflect logged-out state
return; // Exit early
}
}
if (this.token) {
// If token exists, try to validate it and fetch/confirm user_info
// This is crucial if user_info was missing or to refresh/validate existing user_info
console.log('[Auth.js] Token found. Validating and fetching user info...');
try {
const response = await fetch('/api/auth/validate-token', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
if (response.ok) {
const data = await response.json();
if (data.valid && data.user && data.user.id) {
this.currentUser = data.user;
localStorage.setItem('user_info', JSON.stringify(this.currentUser)); // Ensure localStorage is up-to-date
console.log('[Auth.js] Token validated, user_info updated/confirmed:', this.currentUser);
} else {
console.warn('[Auth.js] Token validation failed or user data invalid from API. Clearing auth data.');
this.clearAuthData();
}
} else {
console.warn(`[Auth.js] Token validation API call failed (status: ${response.status}). Clearing auth data.`);
this.clearAuthData();
}
} catch (error) {
console.error('[Auth.js] Error validating token / fetching user info:', error);
this.clearAuthData();
}
} else {
// No token, ensure everything is cleared
if (this.currentUser) { // If there was user_info but no token, clear user_info
console.log('[Auth.js] No token found, but user_info was present. Clearing user_info.');
this.clearAuthData();
}
}
this.updateUIBasedOnAuthState();
}
updateUIBasedOnAuthState() {
const isAuthenticated = this.isAuthenticated();
this._updateDOMForAuthState(isAuthenticated, this.currentUser);
this.dispatchAuthStateEvent(isAuthenticated, this.currentUser);
}
if (authToken && userInfo) {
_updateDOMForAuthState(isAuthenticated, user) {
const authContainer = document.getElementById('authContainer');
const userMenu = document.getElementById('userMenu');
const userDisplayName = document.getElementById('userDisplayName');
const userNameMenu = document.getElementById('userName');
const userEmailMenu = document.getElementById('userEmail');
const logoutMenuItem = document.getElementById('logoutMenuItem');
// Select all potential login/register buttons more broadly
const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn');
const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn');
const genericAuthButtonsContainers = document.querySelectorAll('.auth-buttons');
if (isAuthenticated && user) {
console.log('Auth.js: Updating UI for AUTHENTICATED user:', user);
if (authContainer) { authContainer.style.display = 'none'; authContainer.style.visibility = 'hidden'; }
if (userMenu) {
userMenu.style.display = 'block'; // Or 'flex' based on CSS
userMenu.style.visibility = 'visible';
const displayNameText = user.first_name || user.username || 'User';
const fullNameText = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.username || 'User Name';
if (userDisplayName) userDisplayName.textContent = displayNameText;
if (userNameMenu) userNameMenu.textContent = fullNameText;
if (userEmailMenu && user.email) userEmailMenu.textContent = user.email;
}
loginButtons.forEach(btn => { btn.style.display = 'none'; btn.style.visibility = 'hidden'; });
registerButtons.forEach(btn => { btn.style.display = 'none'; btn.style.visibility = 'hidden'; });
genericAuthButtonsContainers.forEach(container => {
if (container.id !== 'authContainer') { // Avoid double-hiding if authContainer also has .auth-buttons
container.style.display = 'none'; container.style.visibility = 'hidden';
}
});
if (logoutMenuItem) {
logoutMenuItem.style.display = 'flex'; // Assuming it's a flex item
// Ensure logout listener is attached (can be done once in constructor or DOMContentLoaded)
}
} else {
console.log('Auth.js: Updating UI for UNAUTHENTICATED user.');
if (authContainer) { authContainer.style.display = 'flex'; authContainer.style.visibility = 'visible'; }
if (userMenu) { userMenu.style.display = 'none'; userMenu.style.visibility = 'hidden'; }
// Reset user display names if they exist
if (userDisplayName) userDisplayName.textContent = 'User';
if (userNameMenu) userNameMenu.textContent = 'User Name';
if (userEmailMenu) userEmailMenu.textContent = 'user@example.com';
loginButtons.forEach(btn => { btn.style.display = 'inline-block'; btn.style.visibility = 'visible'; });
registerButtons.forEach(btn => { btn.style.display = 'inline-block'; btn.style.visibility = 'visible'; });
genericAuthButtonsContainers.forEach(container => {
if (container.id !== 'authContainer') {
container.style.display = 'flex'; // Or 'block'
container.style.visibility = 'visible';
}
});
if (logoutMenuItem) { logoutMenuItem.style.display = 'none'; }
}
}
dispatchAuthStateEvent(isAuthenticated, user) {
console.log('[Auth.js] Dispatching authStateReady event', { isAuthenticated, user });
// Ensure this event is dispatched after the current call stack clears
setTimeout(() => {
window.dispatchEvent(new CustomEvent('authStateReady', {
detail: { isAuthenticated, user }
}));
}, 0);
}
async login(username, password) {
// Assuming showLoading/hideLoading are global or part of another module
if (typeof showLoading === 'function') showLoading();
try {
currentUser = JSON.parse(userInfo);
updateUIForAuthenticatedUser();
validateToken();
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
this.token = data.token;
this.currentUser = data.user;
localStorage.setItem('auth_token', this.token);
localStorage.setItem('user_info', JSON.stringify(this.currentUser));
this.updateUIBasedOnAuthState();
return data; // Return data for login.js to handle redirect
} else {
throw new Error(data.message || 'Login failed');
}
} catch (error) {
console.error('Error parsing user info:', error);
clearAuthData();
updateUIForUnauthenticatedUser();
this.clearAuthData(); // Ensure state is cleared on login failure
this.updateUIBasedOnAuthState();
throw error; // Re-throw for login.js to handle
} finally {
if (typeof hideLoading === 'function') hideLoading();
}
}
async logout() {
if (typeof showLoading === 'function') showLoading();
const currentTokenForApiCall = this.token; // Use current token for API call
this.clearAuthData(); // Clear local state immediately
this.updateUIBasedOnAuthState(); // Update UI to logged-out state
try {
if (currentTokenForApiCall) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentTokenForApiCall}` }
});
console.log('[Auth.js] Logout API call successful.');
}
} catch (error) {
console.error('[Auth.js] Logout API call failed, but user is logged out locally.', error);
} finally {
if (typeof hideLoading === 'function') hideLoading();
// Redirect to login page after all operations
if (window.location.pathname !== '/login.html') {
window.location.href = 'login.html';
}
}
}
addAuthHeader(options = {}) {
const token = this.getToken();
if (!token) return options;
const headers = options.headers || {};
return { ...options, headers: { ...headers, 'Authorization': `Bearer ${token}`}};
}
}
// Initialize and export
window.auth = new AuthManager();
// Initial check on DOMContentLoaded
document.addEventListener('DOMContentLoaded', async () => {
console.log('[Auth.js] DOMContentLoaded - performing initial async auth state check.');
await window.auth.checkAuthState(true); // Ensure auth state is processed first
// Setup user menu toggle
const userMenuBtn_original = document.getElementById('userMenuBtn');
const userMenuDropdown = document.getElementById('userMenuDropdown');
if (userMenuBtn_original && userMenuDropdown) {
console.log('[Auth.js] Setting up user menu. Button:', userMenuBtn_original, 'Dropdown:', userMenuDropdown);
// Robust listener attachment: clone the button to remove any prior listeners
const userMenuBtn = userMenuBtn_original.cloneNode(true);
userMenuBtn_original.parentNode.replaceChild(userMenuBtn, userMenuBtn_original);
userMenuBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent click from immediately closing due to document listener
console.log('[Auth.js] userMenuBtn clicked - DEBUG INFO:', {
userMenuDropdown: !!userMenuDropdown,
dropdownClassList: userMenuDropdown ? Array.from(userMenuDropdown.classList) : 'not found',
hasActiveClass: userMenuDropdown ? userMenuDropdown.classList.contains('active') : 'dropdown not found',
buttonId: userMenuBtn.id,
dropdownId: userMenuDropdown ? userMenuDropdown.id : 'not found'
});
userMenuDropdown.classList.toggle('active');
const isNowActive = userMenuDropdown.classList.contains('active');
console.log('[Auth.js] User menu toggled via userMenuBtn. Active:', isNowActive);
// Add a temporary debug check to see if it gets closed immediately
setTimeout(() => {
const stillActive = userMenuDropdown.classList.contains('active');
console.log('[Auth.js] User menu status after 100ms:', stillActive);
if (isNowActive && !stillActive) {
console.warn('[Auth.js] User menu was closed immediately! Possible global click interference.');
}
}, 100);
});
console.log('[Auth.js] User menu click listener attached to userMenuBtn.');
} else {
updateUIForUnauthenticatedUser();
}
}
/**
* Update UI elements for authenticated user
*/
function updateUIForAuthenticatedUser() {
// Fire a global event so other scripts (like script.js) can react to authentication being ready
setTimeout(() => {
console.log('[auth.js] Dispatching authStateReady event');
window.dispatchEvent(new Event('authStateReady'));
}, 0);
console.log('auth.js: Updating UI for authenticated user');
// Log the user data being used
console.log('auth.js: Current user data from state:', currentUser);
if (!currentUser) {
console.error('auth.js: Cannot update UI, currentUser is null or undefined.');
return;
console.warn('[Auth.js] User menu button (userMenuBtn) or dropdown (userMenuDropdown) not found. Menu interactivity might be affected.');
}
// Hide login/register buttons
if (authContainer) {
console.log('auth.js: Hiding authContainer');
authContainer.style.display = 'none';
authContainer.style.visibility = 'hidden';
}
// Show user menu
if (userMenu) {
console.log('auth.js: Showing userMenu');
userMenu.style.display = 'block';
userMenu.style.visibility = 'visible';
}
// Update user info in the header menu
let displayName = currentUser.username || 'User';
const fullName = `${currentUser.first_name || ''} ${currentUser.last_name || ''}`.trim() || currentUser.username || 'User Name'; // Fallback added
const email = currentUser.email || 'user@example.com'; // Fallback added
// Log the values being set
console.log(`auth.js: Setting display name (short): [${displayName}]`);
console.log(`auth.js: Setting full name (menu): [${fullName}]`);
console.log(`auth.js: Setting email (menu): [${email}]`);
// Setup settings gear menu toggle (if elements exist on the current page)
const settingsBtn_original = document.getElementById('settingsBtn');
const settingsMenu = document.getElementById('settingsMenu'); // The dropdown menu itself
if (settingsBtn_original && settingsMenu) {
const settingsBtn = settingsBtn_original.cloneNode(true);
settingsBtn_original.parentNode.replaceChild(settingsBtn, settingsBtn_original);
if (userDisplayName) userDisplayName.textContent = displayName;
if (userName) userName.textContent = fullName;
if (userEmail) userEmail.textContent = email;
// Hide all login and register buttons
const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn, .auth-btn.login-btn');
const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn, .auth-btn.register-btn');
console.log('auth.js: Found login buttons:', loginButtons.length);
console.log('auth.js: Found register buttons:', registerButtons.length);
loginButtons.forEach(button => {
console.log('auth.js: Hiding login button');
button.style.display = 'none';
button.style.visibility = 'hidden';
});
registerButtons.forEach(button => {
console.log('auth.js: Hiding register button');
button.style.display = 'none';
button.style.visibility = 'hidden';
});
// Hide any containers with the auth-buttons class
const authButtonsContainers = document.querySelectorAll('.auth-buttons');
authButtonsContainers.forEach(container => {
console.log('auth.js: Hiding auth buttons container');
container.style.display = 'none';
container.style.visibility = 'hidden';
});
}
/**
* Update UI elements for unauthenticated user
*/
function updateUIForUnauthenticatedUser() {
console.log('auth.js: Updating UI for unauthenticated user');
// Show login/register buttons
if (authContainer) {
console.log('auth.js: Showing authContainer');
authContainer.style.display = 'flex';
authContainer.style.visibility = 'visible';
settingsBtn.addEventListener('click', function(e) {
e.stopPropagation();
settingsMenu.classList.toggle('active');
console.log('[Auth.js] Settings menu toggled via settingsBtn.');
});
console.log('[Auth.js] Settings menu click listener attached to settingsBtn.');
}
// Hide user menu
if (userMenu) {
console.log('auth.js: Hiding userMenu');
userMenu.style.display = 'none';
userMenu.style.visibility = 'hidden';
}
// Clear user info
if (userDisplayName) userDisplayName.textContent = 'User';
if (userName) userName.textContent = 'User Name';
if (userEmail) userEmail.textContent = 'user@example.com';
// Show all login and register buttons
const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn, .auth-btn.login-btn');
const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn, .auth-btn.register-btn');
loginButtons.forEach(button => {
button.style.display = 'inline-block';
button.style.visibility = 'visible';
});
registerButtons.forEach(button => {
button.style.display = 'inline-block';
button.style.visibility = 'visible';
});
// Show any containers with the auth-buttons class
const authButtonsContainers = document.querySelectorAll('.auth-buttons');
authButtonsContainers.forEach(container => {
container.style.display = 'flex';
container.style.visibility = 'visible';
});
}
/**
* Logout user
*/
async function logout() {
try {
showLoading();
// Call logout API
const response = await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
// Global click listener to close dropdowns - ensure this is added only once
if (!window._authJsGlobalClickListenerAdded) {
document.addEventListener('click', (e) => {
console.log('[Auth.js] Global click detected on:', e.target);
// Re-fetch elements by ID inside the listener to ensure they are current
const currentDropdown = document.getElementById('userMenuDropdown');
const currentButton = document.getElementById('userMenuBtn'); // Use the standardized ID
if (currentDropdown && currentButton && currentDropdown.classList.contains('active')) {
console.log('[Auth.js] User menu is active, checking if click is outside...');
const isOutsideDropdown = !currentDropdown.contains(e.target);
const isOutsideButton = !currentButton.contains(e.target);
console.log('[Auth.js] Click outside dropdown:', isOutsideDropdown, 'outside button:', isOutsideButton);
if (isOutsideDropdown && isOutsideButton) {
currentDropdown.classList.remove('active');
console.log('[Auth.js] User menu closed by global click.');
}
}
const currentSettingsMenu = document.getElementById('settingsMenu');
const currentSettingsBtn = document.getElementById('settingsBtn');
if (currentSettingsMenu && currentSettingsBtn && currentSettingsMenu.classList.contains('active') &&
!currentSettingsMenu.contains(e.target) && !currentSettingsBtn.contains(e.target)) {
currentSettingsMenu.classList.remove('active');
console.log('[Auth.js] Settings menu closed by global click.');
}
});
// Clear auth data regardless of API response
clearAuthData();
updateUIForUnauthenticatedUser();
// Show success message
showToast('Logged out successfully', 'success');
// Redirect to login page
window.location.href = 'login.html';
} catch (error) {
console.error('Logout error:', error);
// Still clear auth data even if API call fails
clearAuthData();
updateUIForUnauthenticatedUser();
showToast('Logged out', 'info');
// Redirect to login page even if there was an error
window.location.href = 'login.html';
} finally {
hideLoading();
window._authJsGlobalClickListenerAdded = true;
console.log('[Auth.js] Global click listener for dropdowns added.');
}
}
/**
* Clear authentication data from localStorage
*/
function clearAuthData() {
// Clear both localStorage and global variables
localStorage.removeItem('auth_token');
localStorage.removeItem('user_info');
authToken = null;
currentUser = null;
}
/**
* Validate token with the server
*/
async function validateToken() {
if (!authToken) {
clearAuthData();
updateUIForUnauthenticatedUser();
return;
}
try {
// Use the full URL to avoid path issues
const apiUrl = window.location.origin + '/api/auth/validate-token';
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${authToken}`
}
// Attach logout listener to logout menu item
const logoutMenuItem_original = document.getElementById('logoutMenuItem');
if (logoutMenuItem_original) {
const logoutMenuItem = logoutMenuItem_original.cloneNode(true); // Ensures fresh listener
logoutMenuItem_original.parentNode.replaceChild(logoutMenuItem, logoutMenuItem_original);
logoutMenuItem.addEventListener('click', () => {
console.log('[Auth.js] Logout menu item clicked.');
window.auth.logout();
});
if (!response.ok) {
const errorData = await response.json();
console.error('Token validation failed:', errorData.message);
throw new Error(errorData.message || 'Invalid token');
}
// Token is valid, update last active time
const data = await response.json();
if (data.user) {
currentUser = data.user;
localStorage.setItem('user_info', JSON.stringify(currentUser));
updateUIForAuthenticatedUser();
}
return true;
} catch (error) {
console.error('Token validation error:', error);
clearAuthData();
updateUIForUnauthenticatedUser();
// Only show toast if we're on a page that requires authentication
if (window.location.pathname !== '/login.html' &&
window.location.pathname !== '/register.html' &&
window.location.pathname !== '/reset-password.html' &&
window.location.pathname !== '/reset-password-request.html') {
showToast('Your session has expired. Please login again.', 'warning');
}
return false;
console.log('[Auth.js] Logout menu item listener attached.');
}
}
/**
* Add authorization header to fetch requests
* @param {Object} options - Fetch options
* @returns {Object} - Updated fetch options with auth header
*/
function addAuthHeader(options = {}) {
if (!authToken) return options;
const headers = options.headers || {};
return {
...options,
headers: {
...headers,
'Authorization': `Bearer ${authToken}`
}
};
}
/**
* Get the authentication token
* @returns {string} - The authentication token
*/
function getToken() {
// Always get the latest token from localStorage and update global variable
authToken = localStorage.getItem('auth_token');
return authToken;
}
// Export authentication functions for use in other scripts
window.auth = {
isAuthenticated: () => !!localStorage.getItem('auth_token'),
getCurrentUser: () => {
const userInfo = localStorage.getItem('user_info');
return userInfo ? JSON.parse(userInfo) : null;
},
getToken: getToken,
addAuthHeader,
checkAuthState,
logout
};
});

14
frontend/chart.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -9,7 +9,7 @@ console.log('fix-auth-buttons.js loaded and executing');
// Function to check if user is authenticated
function isAuthenticated() {
const token = localStorage.getItem('auth_token');
console.log('Auth token check:', !!token);
// console.log('Auth token check:', !!token); // Keep console logs minimal here if auth.js is primary
return !!token;
}
@@ -21,165 +21,56 @@ function getElementsByText(selector, text) {
// Function to hide login and register buttons if user is authenticated
function updateAuthButtons() {
console.log('updateAuthButtons executing...');
// Check if user is authenticated
// console.log('fix-auth-buttons.js: updateAuthButtons executing...'); // Keep console logs minimal here
if (isAuthenticated()) {
console.log('User is authenticated, hiding login/register buttons');
// Find login and register buttons using valid selectors
// console.log('fix-auth-buttons.js: User is authenticated, hiding login/register buttons');
const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn, .auth-btn.login-btn');
const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn, .auth-btn.register-btn');
// Find buttons by text content
const loginButtonsByText = getElementsByText('a, button', 'Login');
const registerButtonsByText = getElementsByText('a, button', 'Register');
const allLoginButtons = [...loginButtons, ...loginButtonsByText];
const allRegisterButtons = [...registerButtons, ...registerButtonsByText];
console.log('Found login buttons:', allLoginButtons.length);
console.log('Found register buttons:', allRegisterButtons.length);
// Hide buttons if they exist
allLoginButtons.forEach(button => {
console.log('Hiding login button:', button);
button.style.display = 'none';
button.style.visibility = 'hidden';
});
allRegisterButtons.forEach(button => {
console.log('Hiding register button:', button);
button.style.display = 'none';
button.style.visibility = 'hidden';
});
// Hide auth container if it exists
const authContainer = document.getElementById('authContainer');
if (authContainer) {
console.log('Hiding auth container');
authContainer.style.display = 'none';
authContainer.style.visibility = 'hidden';
}
// Also try to hide by class
const authButtonsContainers = document.querySelectorAll('.auth-buttons');
console.log('Found auth buttons containers:', authButtonsContainers.length);
authButtonsContainers.forEach(container => {
console.log('Hiding auth buttons container');
container.style.display = 'none';
container.style.visibility = 'hidden';
});
// Show user menu if it exists
const userMenu = document.getElementById('userMenu');
if (userMenu) {
console.log('Showing user menu');
userMenu.style.display = 'block';
userMenu.style.visibility = 'visible';
}
// Show username if it exists
const userMenu = document.getElementById('userMenu'); // Ensure this ID is consistent or use userMenuBtn's parent
loginButtons.forEach(button => { button.style.display = 'none'; button.style.visibility = 'hidden'; });
registerButtons.forEach(button => { button.style.display = 'none'; button.style.visibility = 'hidden'; });
if (authContainer) { authContainer.style.display = 'none'; authContainer.style.visibility = 'hidden';}
if (userMenu) { userMenu.style.display = 'block'; userMenu.style.visibility = 'visible'; }
const userInfo = localStorage.getItem('user_info');
if (userInfo) {
try {
const user = JSON.parse(userInfo);
const username = user.username || 'User';
console.log('Setting username to:', username);
// Find username display elements
const usernameDisplays = document.querySelectorAll('.user-name, #userDisplayName, #userName');
usernameDisplays.forEach(display => {
if (display) {
display.textContent = username;
display.style.display = 'inline-block';
}
});
// Set user email if element exists
const userEmail = document.getElementById('userEmail');
if (userEmail && user.email) {
userEmail.textContent = user.email;
const displayName = user.first_name || user.username || 'User';
const userDisplayName = document.getElementById('userDisplayName');
if (userDisplayName) userDisplayName.textContent = displayName;
const userNameMenu = document.getElementById('userName');
if (userNameMenu) {
userNameMenu.textContent = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.username || 'User Name';
}
} catch (error) {
console.error('Error parsing user info:', error);
}
const userEmailMenu = document.getElementById('userEmail');
if (userEmailMenu && user.email) userEmailMenu.textContent = user.email;
} catch (error) { /* console.error('fix-auth-buttons.js: Error parsing user info:', error); */ }
}
} else {
console.log('User is not authenticated, showing login/register buttons');
// Find login and register buttons in the new UI
// console.log('fix-auth-buttons.js: User is not authenticated, showing login/register buttons');
const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn, .auth-btn.login-btn');
const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn, .auth-btn.register-btn');
// Show buttons if they exist
loginButtons.forEach(button => {
button.style.display = 'inline-block';
button.style.visibility = 'visible';
});
registerButtons.forEach(button => {
button.style.display = 'inline-block';
button.style.visibility = 'visible';
});
// Show auth container if it exists
const authContainer = document.getElementById('authContainer');
if (authContainer) {
authContainer.style.display = 'flex';
authContainer.style.visibility = 'visible';
}
// Also try to show by class
const authButtonsContainers = document.querySelectorAll('.auth-buttons');
authButtonsContainers.forEach(container => {
container.style.display = 'flex';
container.style.visibility = 'visible';
});
// Hide user menu if it exists
const userMenu = document.getElementById('userMenu');
if (userMenu) {
userMenu.style.display = 'none';
userMenu.style.visibility = 'hidden';
}
}
}
// --- USER MENU DROPDOWN UNIVERSAL FIX (IMPROVED) ---
function setupUserMenuDropdown() {
const userMenuBtn = document.getElementById('userMenuBtn') || document.getElementById('userBtn');
const userMenuDropdown = document.getElementById('userMenuDropdown');
if (userMenuBtn && userMenuDropdown) {
// Remove all previous click listeners by replacing the node
const newBtn = userMenuBtn.cloneNode(true);
userMenuBtn.parentNode.replaceChild(newBtn, userMenuBtn);
newBtn.addEventListener('click', function(e) {
e.stopPropagation();
userMenuDropdown.classList.toggle('active');
});
// Only add one document-level listener
if (!window._userMenuDropdownListenerAdded) {
document.addEventListener('click', function(e) {
if (userMenuDropdown.classList.contains('active') &&
!userMenuDropdown.contains(e.target) &&
!newBtn.contains(e.target)) {
userMenuDropdown.classList.remove('active');
}
});
window._userMenuDropdownListenerAdded = true;
}
loginButtons.forEach(button => { button.style.display = 'inline-block'; button.style.visibility = 'visible'; });
registerButtons.forEach(button => { button.style.display = 'inline-block'; button.style.visibility = 'visible'; });
if (authContainer) { authContainer.style.display = 'flex'; authContainer.style.visibility = 'visible'; }
if (userMenu) { userMenu.style.display = 'none'; userMenu.style.visibility = 'hidden'; }
}
}
// Run immediately
console.log('Running updateAuthButtons immediately');
// console.log('Running updateAuthButtons immediately from fix-auth-buttons.js');
updateAuthButtons();
setupUserMenuDropdown();
// Update auth buttons when page loads
document.addEventListener('DOMContentLoaded', () => {
console.log('DOMContentLoaded event triggered, updating auth buttons');
// console.log('DOMContentLoaded event triggered in fix-auth-buttons.js, updating auth buttons');
updateAuthButtons();
setupUserMenuDropdown();
// REMOVE: setupUserMenuDropdown();
});

View File

@@ -116,7 +116,7 @@
</a>
</div>
<div id="userMenu" class="user-menu" style="display: none;">
<button id="userBtn" class="user-btn">
<button id="userMenuBtn" class="user-btn">
<i class="fas fa-user-circle"></i>
<span id="userDisplayName">User</span>
</button>
@@ -336,6 +336,21 @@
</div>
<!-- End Lifetime Checkbox -->
<!-- Warranty Entry Method Selection -->
<div class="form-group" id="warrantyEntryMethod">
<label>Warranty Entry Method</label>
<div class="warranty-method-options">
<label class="radio-option">
<input type="radio" id="durationMethod" name="warranty_method" value="duration" checked>
<span>Warranty Duration</span>
</label>
<label class="radio-option">
<input type="radio" id="exactDateMethod" name="warranty_method" value="exact_date">
<span>Exact Expiration Date</span>
</label>
</div>
</div>
<div id="warrantyDurationFields">
<div class="form-group">
<label for="warrantyDurationYears">Warranty Period</label>
@@ -356,6 +371,13 @@
</div>
</div>
<div id="exactExpirationField" style="display: none;">
<div class="form-group">
<label for="exactExpirationDate">Expiration Date</label>
<input type="date" id="exactExpirationDate" name="exact_expiration_date" class="form-control">
</div>
</div>
<div class="form-group">
<label for="purchasePrice">Purchase Price (Optional)</label>
<div class="price-input-wrapper">
@@ -580,6 +602,21 @@
</div>
<!-- End Lifetime Checkbox -->
<!-- Warranty Entry Method Selection -->
<div class="form-group" id="editWarrantyEntryMethod">
<label>Warranty Entry Method</label>
<div class="warranty-method-options">
<label class="radio-option">
<input type="radio" id="editDurationMethod" name="edit_warranty_method" value="duration" checked>
<span>Warranty Duration</span>
</label>
<label class="radio-option">
<input type="radio" id="editExactDateMethod" name="edit_warranty_method" value="exact_date">
<span>Exact Expiration Date</span>
</label>
</div>
</div>
<div id="editWarrantyDurationFields">
<div class="form-group">
<label for="editWarrantyDurationYears">Warranty Period</label>
@@ -600,6 +637,13 @@
</div>
</div>
<div id="editExactExpirationField" style="display: none;">
<div class="form-group">
<label for="editExactExpirationDate">Expiration Date</label>
<input type="date" id="editExactExpirationDate" name="exact_expiration_date" class="form-control">
</div>
</div>
<div class="form-group">
<label for="editPurchasePrice">Purchase Price (Optional)</label>
<div class="price-input-wrapper">
@@ -740,7 +784,51 @@
</div>
<script src="auth.js"></script>
<script src="script.js"></script>
<script src="script.js?v=20250529004"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
// Apply footer styles based on theme
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' ||
document.documentElement.classList.contains('dark-mode') ||
document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
// Apply styles when page loads
document.addEventListener('DOMContentLoaded', applyFooterStyles);
// Watch for theme changes
const observer = new MutationObserver(applyFooterStyles);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class']
});
// Also watch body for theme changes
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
</script>
</body>
</html>

View File

@@ -95,6 +95,151 @@
color: var(--text-muted);
cursor: pointer;
}
/* Provider-specific SSO button styles */
.btn-google {
background-color: #4285f4;
border-color: #4285f4;
color: white;
}
.btn-google:hover, .btn-google:focus {
background-color: #357ae8;
border-color: #357ae8;
color: white;
}
.btn-github {
background-color: #333;
border-color: #333;
color: white;
}
.btn-github:hover, .btn-github:focus {
background-color: #24292e;
border-color: #24292e;
color: white;
}
.btn-microsoft {
background-color: #0078d4;
border-color: #0078d4;
color: white;
}
.btn-microsoft:hover, .btn-microsoft:focus {
background-color: #106ebe;
border-color: #106ebe;
color: white;
}
.btn-facebook {
background-color: #1877f2;
border-color: #1877f2;
color: white;
}
.btn-facebook:hover, .btn-facebook:focus {
background-color: #166fe5;
border-color: #166fe5;
color: white;
}
.btn-twitter {
background-color: #1da1f2;
border-color: #1da1f2;
color: white;
}
.btn-twitter:hover, .btn-twitter:focus {
background-color: #0d95e8;
border-color: #0d95e8;
color: white;
}
.btn-linkedin {
background-color: #0077b5;
border-color: #0077b5;
color: white;
}
.btn-linkedin:hover, .btn-linkedin:focus {
background-color: #005885;
border-color: #005885;
color: white;
}
.btn-apple {
background-color: #000;
border-color: #000;
color: white;
}
.btn-apple:hover, .btn-apple:focus {
background-color: #333;
border-color: #333;
color: white;
}
.btn-discord {
background-color: #5865f2;
border-color: #5865f2;
color: white;
}
.btn-discord:hover, .btn-discord:focus {
background-color: #4752c4;
border-color: #4752c4;
color: white;
}
.btn-gitlab {
background-color: #fca326;
border-color: #fca326;
color: white;
}
.btn-gitlab:hover, .btn-gitlab:focus {
background-color: #fc9403;
border-color: #fc9403;
color: white;
}
.btn-bitbucket {
background-color: #0052cc;
border-color: #0052cc;
color: white;
}
.btn-bitbucket:hover, .btn-bitbucket:focus {
background-color: #0047b3;
border-color: #0047b3;
color: white;
}
.btn-keycloak {
background-color: #4d4d4d;
border-color: #4d4d4d;
color: white;
}
.btn-keycloak:hover, .btn-keycloak:focus {
background-color: #333;
border-color: #333;
color: white;
}
.btn-okta {
background-color: #007dc1;
border-color: #007dc1;
color: white;
}
.btn-okta:hover, .btn-okta:focus {
background-color: #005a8b;
border-color: #005a8b;
color: white;
}
</style>
<!-- Registration status check script -->
@@ -138,8 +283,17 @@
<i class="fas fa-sign-in-alt"></i> Login
</button>
</form>
<div style="text-align: center; margin: 20px 0; color: var(--text-muted);">
<hr style="border-top: 1px solid var(--border-color); margin-bottom: 10px;">
OR
</div>
<a href="/api/oidc/login" id="oidcLoginButton" class="btn btn-secondary btn-block" style="text-decoration: none; display: flex; align-items: center; justify-content: center;">
<i class="fab fa-openid" style="margin-right: 8px;"></i> Login with SSO Provider
</a>
<div class="auth-links">
<div class="auth-links" style="margin-top: 30px;">
<a href="register.html">Create Account</a>
<a href="reset-password-request.html">Forgot Password?</a>
</div>
@@ -147,9 +301,156 @@
</div>
<!-- Script for authentication -->
<script src="auth.js"></script>
<script src="auth.js?v=20250529002"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Check for OIDC errors in URL parameters and display them
const urlParams = new URLSearchParams(window.location.search);
const oidcError = urlParams.get('oidc_error');
if (oidcError) {
let errorMessage = 'An unknown error occurred during SSO login.';
switch (oidcError) {
case 'oidc_disabled':
errorMessage = 'SSO login is currently disabled.';
break;
case 'oidc_misconfigured':
errorMessage = 'SSO is not properly configured. Please contact your administrator.';
break;
case 'token_exchange_failed':
errorMessage = 'Failed to exchange authorization code for tokens with the SSO provider.';
break;
case 'token_missing':
errorMessage = 'Access token was not received from the SSO provider.';
break;
case 'userinfo_fetch_failed':
errorMessage = 'Failed to fetch user information from the SSO provider.';
break;
case 'userinfo_missing':
errorMessage = 'User information was not received from the SSO provider.';
break;
case 'subject_missing':
errorMessage = 'User subject identifier (sub) was missing from SSO provider response.';
break;
case 'email_missing_for_new_user':
errorMessage = 'Email address was not provided by SSO provider, which is required for new user registration.';
break;
case 'email_conflict_local_account':
errorMessage = 'The email address from your SSO provider is already associated with an existing local account. Please log in with your local credentials or contact support.';
break;
case 'registration_disabled':
errorMessage = 'New user registration via SSO is currently disabled. Only existing users can log in with SSO. Please contact your administrator if you need an account.';
break;
case 'user_processing_failed':
errorMessage = 'Failed to process user information after SSO login.';
break;
case 'db_error':
errorMessage = 'A database error occurred during SSO login. Please try again later.';
break;
case 'internal_error':
errorMessage = 'An internal server error occurred during SSO login. Please try again later.';
break;
}
showMessage(errorMessage, 'error');
// Clean up the URL by removing the oidc_error parameter
const newUrl = new URL(window.location);
newUrl.searchParams.delete('oidc_error');
window.history.replaceState({}, document.title, newUrl);
}
// Fetch OIDC status and customize button based on provider
fetch('/api/auth/oidc-status')
.then(response => response.json())
.then(data => {
const oidcButton = document.getElementById('oidcLoginButton');
const orSeparator = document.querySelector('.auth-form + div[style*="text-align: center"]');
if (!data.oidc_enabled) {
if (oidcButton) oidcButton.style.display = 'none';
if (orSeparator) orSeparator.style.display = 'none';
return;
}
// Customize button based on provider
if (oidcButton && data.oidc_provider_display_name) {
const providerName = data.oidc_provider_display_name.toLowerCase();
let buttonText = `Login with ${data.oidc_provider_display_name}`;
let iconClass = 'fab fa-openid'; // Default icon
let buttonClass = 'btn-secondary'; // Default class
// Provider-specific customizations
switch (providerName) {
case 'google':
iconClass = 'fab fa-google';
buttonClass = 'btn-google';
break;
case 'github':
iconClass = 'fab fa-github';
buttonClass = 'btn-github';
break;
case 'microsoft':
case 'azure':
case 'office365':
iconClass = 'fab fa-microsoft';
buttonClass = 'btn-microsoft';
break;
case 'facebook':
iconClass = 'fab fa-facebook-f';
buttonClass = 'btn-facebook';
break;
case 'twitter':
iconClass = 'fab fa-twitter';
buttonClass = 'btn-twitter';
break;
case 'linkedin':
iconClass = 'fab fa-linkedin-in';
buttonClass = 'btn-linkedin';
break;
case 'apple':
iconClass = 'fab fa-apple';
buttonClass = 'btn-apple';
break;
case 'discord':
iconClass = 'fab fa-discord';
buttonClass = 'btn-discord';
break;
case 'gitlab':
iconClass = 'fab fa-gitlab';
buttonClass = 'btn-gitlab';
break;
case 'bitbucket':
iconClass = 'fab fa-bitbucket';
buttonClass = 'btn-bitbucket';
break;
case 'keycloak':
iconClass = 'fas fa-key';
buttonClass = 'btn-keycloak';
break;
case 'okta':
iconClass = 'fas fa-shield-alt';
buttonClass = 'btn-okta';
break;
default:
// Keep generic styling for unknown providers
buttonText = `Login with ${data.oidc_provider_display_name}`;
break;
}
// Update button styling and content
oidcButton.className = `btn ${buttonClass} btn-block`;
oidcButton.innerHTML = `<i class="${iconClass}" style="margin-right: 8px;"></i> ${buttonText}`;
}
})
.catch(error => {
console.error('Error fetching OIDC status:', error);
// Hide button on error as a safe default
const oidcButton = document.getElementById('oidcLoginButton');
const orSeparator = document.querySelector('.auth-form + div[style*="text-align: center"]');
if (oidcButton) oidcButton.style.display = 'none';
if (orSeparator) orSeparator.style.display = 'none';
});
// Toggle password visibility
const passwordToggle = document.querySelector('.password-toggle');
const passwordInput = document.getElementById('password');
@@ -166,7 +467,6 @@
// Handle form submission
const loginForm = document.getElementById('loginForm');
const authMessage = document.getElementById('authMessage');
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
@@ -229,6 +529,7 @@
// Helper function to show messages
function showMessage(message, type) {
const authMessage = document.getElementById('authMessage');
authMessage.textContent = message;
authMessage.className = 'auth-message';
authMessage.classList.add(type);
@@ -236,5 +537,33 @@
}
});
</script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>

View File

@@ -228,7 +228,7 @@
</style>
<!-- Registration status check script -->
<script src="registration-status.js"></script>
<script src="registration-status.js?v=20250529001"></script>
<script>
// Additional handling for the register page
document.addEventListener('DOMContentLoaded', function() {
@@ -603,5 +603,33 @@
// Initialize theme when page loads
document.addEventListener('DOMContentLoaded', initializeTheme);
</script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>

View File

@@ -237,5 +237,34 @@
// Initialize theme when page loads
document.addEventListener('DOMContentLoaded', initializeTheme);
</script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>

View File

@@ -425,5 +425,34 @@
// Initialize theme when page loads
document.addEventListener('DOMContentLoaded', initializeTheme);
</script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -445,6 +445,61 @@
</form>
</div>
</div>
<!-- OIDC SSO Configuration Card -->
<div class="card">
<div class="card-header">
<h3>OIDC SSO Configuration</h3>
</div>
<div class="card-body">
<form id="oidcSettingsForm">
<div class="form-group">
<div class="preference-item">
<div>
<label for="oidcEnabled">Enable OIDC SSO</label>
<p class="text-muted">Allow users to log in via an OIDC provider.</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="oidcEnabled">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-group">
<label for="oidcProviderName">OIDC Provider Name</label>
<input type="text" id="oidcProviderName" class="form-control" placeholder="e.g., oidc, keycloak, google">
<small class="text-muted">Internal name for the OIDC client (e.g., 'oidc').</small>
</div>
<div class="form-group">
<label for="oidcClientId">Client ID</label>
<input type="text" id="oidcClientId" class="form-control" placeholder="Enter OIDC Client ID">
</div>
<div class="form-group">
<label for="oidcClientSecret">Client Secret</label>
<input type="password" id="oidcClientSecret" class="form-control" placeholder="Enter new secret or leave blank to keep existing">
<small class="text-muted">Sensitive value. Stored in the database. An application restart is required for changes to take effect.</small>
</div>
<div class="form-group">
<label for="oidcIssuerUrl">Issuer URL</label>
<input type="url" id="oidcIssuerUrl" class="form-control" placeholder="e.g., https://your-provider.com/realms/your-realm">
<small class="text-muted">The base URL of your OIDC provider.</small>
</div>
<div class="form-group">
<label for="oidcScope">Scope</label>
<input type="text" id="oidcScope" class="form-control" placeholder="e.g., openid email profile">
<small class="text-muted">Space-separated OIDC scopes.</small>
</div>
<button type="button" id="saveOidcSettingsBtn" class="btn btn-primary">Save OIDC Settings</button>
<p id="oidcRestartMessage" class="text-muted" style="margin-top: 10px; display: none;">Application restart is required for OIDC settings to take full effect.</p>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -617,7 +672,36 @@
<div id="toastContainer" class="toast-container"></div>
<!-- Scripts -->
<script src="auth.js"></script>
<script src="settings-new.js"></script>
<script src="auth.js?v=4"></script>
<script src="settings-new.js?v=20250529001"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>

View File

@@ -43,6 +43,17 @@ const triggerNotificationsBtn = document.getElementById('triggerNotificationsBtn
const registrationEnabled = document.getElementById('registrationEnabled');
const saveSiteSettingsBtn = document.getElementById('saveSiteSettingsBtn');
const emailBaseUrlInput = document.getElementById('emailBaseUrl'); // Added for email base URL
// OIDC Settings DOM Elements
const oidcEnabledToggle = document.getElementById('oidcEnabled');
const oidcProviderNameInput = document.getElementById('oidcProviderName');
const oidcClientIdInput = document.getElementById('oidcClientId');
const oidcClientSecretInput = document.getElementById('oidcClientSecret');
const oidcIssuerUrlInput = document.getElementById('oidcIssuerUrl');
const oidcScopeInput = document.getElementById('oidcScope');
const saveOidcSettingsBtn = document.getElementById('saveOidcSettingsBtn');
const oidcRestartMessage = document.getElementById('oidcRestartMessage');
const currencySymbolInput = document.getElementById('currencySymbol');
const currencySymbolSelect = document.getElementById('currencySymbolSelect');
const currencySymbolCustom = document.getElementById('currencySymbolCustom');
@@ -346,14 +357,7 @@ async function loadUserData() {
if (userEmailDisplay) userEmailDisplay.textContent = currentUser.email || 'N/A';
// --- END UPDATE ---
// Check if user is admin and show admin section
if (currentUser.is_admin) {
if (adminSection) adminSection.style.display = 'block';
if (adminSection && adminSection.style.display === 'block') {
if (usersTableBody) loadUsers();
if (registrationEnabled) loadSiteSettings();
}
}
// Admin section visibility will be determined after API call
}
// Fetch fresh user data from API
@@ -384,13 +388,24 @@ async function loadUserData() {
if (userEmailDisplay) userEmailDisplay.textContent = userData.email || 'N/A';
// --- END UPDATE ---
// Show admin section if user is admin
// Show admin section if user is admin and load admin-specific data
if (userData.is_admin) {
if (adminSection) adminSection.style.display = 'block';
if (adminSection && adminSection.style.display === 'block') {
if (usersTableBody) loadUsers();
if (registrationEnabled) loadSiteSettings();
if (adminSection) {
adminSection.style.display = 'block';
// Ensure admin-specific data is loaded AFTER section is visible
if (usersTableBody) loadUsers();
// Check for site settings elements directly to avoid cache timing issues
const hasRegistrationToggle = document.getElementById('registrationEnabled');
const hasOidcToggle = document.getElementById('oidcEnabled');
if (hasRegistrationToggle || hasOidcToggle) {
console.log('Admin settings elements found, loading site settings...');
loadSiteSettings();
} else {
console.warn('Admin settings elements not found - this might be a timing/cache issue');
}
}
} else {
if (adminSection) adminSection.style.display = 'none';
}
// Update localStorage ONLY if data has changed
@@ -666,27 +681,23 @@ function setupEventListeners() {
console.log('Setting up event listeners');
// Set up user menu button click handler
const userMenuBtn = document.getElementById('userMenuBtn');
const userMenuDropdown = document.getElementById('userMenuDropdown');
if (userMenuBtn && userMenuDropdown) {
console.log('Setting up user menu button click handler');
// Toggle dropdown when user button is clicked
userMenuBtn.addEventListener('click', function(e) {
e.stopPropagation();
userMenuDropdown.classList.toggle('active');
});
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (userMenuDropdown.classList.contains('active') &&
!userMenuDropdown.contains(e.target) &&
!userMenuBtn.contains(e.target)) {
userMenuDropdown.classList.remove('active');
}
});
}
// const userMenuBtn = document.getElementById('userMenuBtn'); // REMOVE/COMMENT OUT
// const userMenuDropdown = document.getElementById('userMenuDropdown'); // REMOVE/COMMENT OUT
// if (userMenuBtn && userMenuDropdown) { // REMOVE/COMMENT OUT THIS ENTIRE BLOCK
// console.log('Setting up user menu button click handler');
// userMenuBtn.addEventListener('click', function(e) {
// e.stopPropagation();
// userMenuDropdown.classList.toggle('active');
// });
// document.addEventListener('click', function(e) {
// if (userMenuDropdown.classList.contains('active') &&
// !userMenuDropdown.contains(e.target) &&
// !userMenuBtn.contains(e.target)) {
// userMenuDropdown.classList.remove('active');
// }
// });
// }
// Dark mode toggle in header (no longer exists)
if (darkModeToggle) {
@@ -807,7 +818,14 @@ function setupEventListeners() {
// Site settings save button
if (saveSiteSettingsBtn) {
saveSiteSettingsBtn.addEventListener('click', function() {
saveSiteSettings();
saveSiteSettings(); // This will now also handle non-OIDC site settings
});
}
// Save OIDC settings button
if (saveOidcSettingsBtn) {
saveOidcSettingsBtn.addEventListener('click', function() {
saveOidcSettings();
});
}
@@ -2374,17 +2392,27 @@ function closeAllModals() {
*/
async function loadSiteSettings() {
console.log('Loading site settings...');
const adminSection = document.getElementById('adminSection');
const registrationToggle = document.getElementById('registrationEnabled');
const emailBaseUrlField = document.getElementById('emailBaseUrl'); // Correct variable name
// Enhanced debugging for element availability
console.log('[SiteSettings Debug] DOM readiness check:');
console.log(' - document.readyState:', document.readyState);
console.log(' - adminSection exists:', !!document.getElementById('adminSection'));
console.log(' - registrationEnabled exists:', !!document.getElementById('registrationEnabled'));
console.log(' - oidcEnabled exists:', !!document.getElementById('oidcEnabled'));
console.log(' - oidcProviderName exists:', !!document.getElementById('oidcProviderName'));
console.log(' - oidcClientId exists:', !!document.getElementById('oidcClientId'));
// Query elements locally within this function scope for population
const registrationToggleElem = document.getElementById('registrationEnabled');
const emailBaseUrlFieldElem = document.getElementById('emailBaseUrl');
const oidcEnabledToggleElem = document.getElementById('oidcEnabled');
const oidcProviderNameInputElem = document.getElementById('oidcProviderName');
const oidcClientIdInputElem = document.getElementById('oidcClientId');
const oidcClientSecretInputElem = document.getElementById('oidcClientSecret');
const oidcIssuerUrlInputElem = document.getElementById('oidcIssuerUrl');
const oidcScopeInputElem = document.getElementById('oidcScope');
// // Check if admin section exists
// if (!adminSection) {
// console.log('Admin section not found, skipping site settings load');
// return;
// }
try { // Correct structure: try block starts
try {
showLoading();
const response = await fetch('/api/admin/settings', {
@@ -2400,75 +2428,125 @@ async function loadSiteSettings() {
}
const settings = await response.json();
console.log('[SiteSettings] Raw settings received from API:', settings);
if (registrationToggle) {
registrationToggle.checked = settings.registration_enabled === 'true';
if (registrationToggleElem) {
registrationToggleElem.checked = settings.registration_enabled === 'true';
} else {
console.error('[SiteSettings] registrationEnabled element NOT FOUND locally.');
}
if (emailBaseUrlField) { // Use correct variable name
emailBaseUrlField.value = settings.email_base_url || 'http://localhost:8080'; // Set the value
if (emailBaseUrlFieldElem) {
emailBaseUrlFieldElem.value = settings.email_base_url || 'http://localhost:8080';
} else {
console.error('[SiteSettings] emailBaseUrl element NOT FOUND locally.');
}
// Populate OIDC settings using locally-scoped element variables
if (oidcEnabledToggleElem) {
console.log('[OIDC Settings] Found oidcEnabledToggleElem. Setting checked to:', settings.oidc_enabled === 'true');
oidcEnabledToggleElem.checked = settings.oidc_enabled === 'true';
} else {
console.error('[OIDC Settings] oidcEnabledToggleElem element NOT FOUND locally.');
}
if (oidcProviderNameInputElem) {
console.log('[OIDC Settings] Found oidcProviderNameInputElem. Setting value to:', settings.oidc_provider_name || 'oidc');
oidcProviderNameInputElem.value = settings.oidc_provider_name || 'oidc';
} else {
console.error('[OIDC Settings] oidcProviderNameInputElem element NOT FOUND locally.');
}
if (oidcClientIdInputElem) {
console.log('[OIDC Settings] Found oidcClientIdInputElem. Setting value to:', settings.oidc_client_id || '');
oidcClientIdInputElem.value = settings.oidc_client_id || '';
} else {
console.error('[OIDC Settings] oidcClientIdInputElem element NOT FOUND locally.');
}
if (oidcClientSecretInputElem) {
console.log('[OIDC Settings] Found oidcClientSecretInputElem. Setting placeholder based on oidc_client_secret_set:', settings.oidc_client_secret_set);
oidcClientSecretInputElem.value = ''; // Always clear on load
oidcClientSecretInputElem.placeholder = settings.oidc_client_secret_set ? '******** (Set - Enter new to change)' : 'Enter OIDC Client Secret';
} else {
console.error('[OIDC Settings] oidcClientSecretInputElem element NOT FOUND locally.');
}
if (oidcIssuerUrlInputElem) {
console.log('[OIDC Settings] Found oidcIssuerUrlInputElem. Setting value to:', settings.oidc_issuer_url || '');
oidcIssuerUrlInputElem.value = settings.oidc_issuer_url || '';
} else {
console.error('[OIDC Settings] oidcIssuerUrlInputElem element NOT FOUND locally.');
}
if (oidcScopeInputElem) {
console.log('[OIDC Settings] Found oidcScopeInputElem. Setting value to:', settings.oidc_scope || 'openid email profile');
oidcScopeInputElem.value = settings.oidc_scope || 'openid email profile';
} else {
console.error('[OIDC Settings] oidcScopeInputElem element NOT FOUND locally.');
}
console.log('Site settings loaded successfully', settings);
console.log('Site and OIDC settings loaded and population attempted using locally queried elements.');
} catch (error) { // Correct structure: catch block follows try
console.error('Error loading site settings:', error);
} catch (error) {
console.error('Error loading or populating site settings:', error);
showToast('Failed to load site settings. Please try again.', 'error');
} finally { // Correct structure: finally block follows catch
} finally {
hideLoading();
}
}
/**
* Save site settings
* Save site settings (non-OIDC part)
*/
async function saveSiteSettings() {
console.log('Saving site settings...');
console.log('Saving site settings (non-OIDC)...');
const registrationToggle = document.getElementById('registrationEnabled');
const emailBaseUrlField = document.getElementById('emailBaseUrl'); // Get the new field
const emailBaseUrlField = document.getElementById('emailBaseUrl');
const settings = {};
const settingsToSave = {};
if (registrationToggle) {
settings.registration_enabled = registrationToggle.checked; // Boolean value will be converted to string in backend
settingsToSave.registration_enabled = registrationToggle.checked;
}
if (emailBaseUrlField) {
let baseUrl = emailBaseUrlField.value.trim();
// Basic validation: check if it looks somewhat like a URL
if (baseUrl && (baseUrl.startsWith('http://') || baseUrl.startsWith('https://'))) {
// Remove trailing slash if present
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
settings.email_base_url = baseUrl;
settingsToSave.email_base_url = baseUrl;
} else if (baseUrl) {
showToast('Invalid Email Base URL format. It should start with http:// or https://', 'error');
return; // Stop saving if format is invalid
return;
} else {
settings.email_base_url = 'http://localhost:8080'; // Use default if empty
emailBaseUrlField.value = settings.email_base_url; // Update field with default
settingsToSave.email_base_url = 'http://localhost:8080';
emailBaseUrlField.value = settingsToSave.email_base_url;
}
}
if (Object.keys(settingsToSave).length === 0) {
showToast('No site settings to save.', 'info');
return;
}
try {
showLoading();
const response = await fetch('/api/admin/settings', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${window.auth.getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
body: JSON.stringify(settingsToSave)
});
const result = await response.json();
if (response.ok) {
showToast('Site settings saved successfully', 'success');
showToast(result.message || 'Site settings saved successfully', 'success');
} else {
const errorData = await response.json();
showToast(errorData.message || 'Failed to save site settings', 'error');
showToast(result.message || 'Failed to save site settings', 'error');
}
} catch (error) {
console.error('Error saving site settings:', error);
@@ -2478,6 +2556,59 @@ async function saveSiteSettings() {
}
}
/**
* Save OIDC settings
*/
async function saveOidcSettings() {
console.log('Saving OIDC settings...');
const oidcSettingsPayload = {
oidc_enabled: oidcEnabledToggle ? oidcEnabledToggle.checked : false,
oidc_provider_name: oidcProviderNameInput ? oidcProviderNameInput.value.trim() : 'oidc',
oidc_client_id: oidcClientIdInput ? oidcClientIdInput.value.trim() : '',
oidc_issuer_url: oidcIssuerUrlInput ? oidcIssuerUrlInput.value.trim() : '',
oidc_scope: oidcScopeInput ? oidcScopeInput.value.trim() : 'openid email profile',
};
// Only include client_secret if a new value is entered
if (oidcClientSecretInput && oidcClientSecretInput.value) {
oidcSettingsPayload.oidc_client_secret = oidcClientSecretInput.value;
}
try {
showLoading();
const response = await fetch('/api/admin/settings', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${window.auth.getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(oidcSettingsPayload)
});
const result = await response.json();
if (response.ok) {
showToast(result.message || 'OIDC settings saved successfully.', 'success');
if (result.message && result.message.includes("restart is required")) {
if(oidcRestartMessage) oidcRestartMessage.style.display = 'block';
} else {
if(oidcRestartMessage) oidcRestartMessage.style.display = 'none';
}
// Clear the secret field after attempting to save
if (oidcClientSecretInput) oidcClientSecretInput.value = '';
// Reload settings to get the oidc_client_secret_set flag updated
loadSiteSettings();
} else {
showToast(result.message || 'Failed to save OIDC settings.', 'error');
}
} catch (error) {
console.error('Error saving OIDC settings:', error);
showToast('Failed to save OIDC settings. Please try again.', 'error');
} finally {
hideLoading();
}
}
/**
* Set up the delete button for user deletion
*/
@@ -3021,4 +3152,4 @@ if (currencySymbolSelect && currencySymbolCustom) {
currencySymbolCustom.value = '';
}
});
}
}

View File

@@ -26,7 +26,7 @@
<!-- Load header fix styles to ensure consistent header styling -->
<link rel="stylesheet" href="header-fix.css">
<!-- Chart.js for visualizations -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="chart.js"></script>
<!-- Load fix for auth buttons -->
<script src="fix-auth-buttons-loader.js"></script>
<script src="script.js" defer></script> <!-- Added script.js -->
@@ -241,7 +241,7 @@
</a>
</div>
<div id="userMenu" class="user-menu" style="display: none;">
<button id="userBtn" class="user-btn">
<button id="userMenuBtn" class="user-btn">
<i class="fas fa-user-circle"></i>
<span id="userDisplayName">User</span>
</button>
@@ -465,6 +465,21 @@
</div>
<!-- End Lifetime Checkbox -->
<!-- Warranty Entry Method Selection -->
<div class="form-group" id="editWarrantyEntryMethod">
<label>Warranty Entry Method</label>
<div class="warranty-method-options">
<label class="radio-option">
<input type="radio" id="editDurationMethod" name="edit_warranty_method" value="duration" checked>
<span>Warranty Duration</span>
</label>
<label class="radio-option">
<input type="radio" id="editExactDateMethod" name="edit_warranty_method" value="exact_date">
<span>Exact Expiration Date</span>
</label>
</div>
</div>
<div id="editWarrantyDurationFields">
<div class="form-group">
<label for="editWarrantyDurationYears">Warranty Period</label>
@@ -485,6 +500,13 @@
</div>
</div>
<div id="editExactExpirationField" style="display: none;">
<div class="form-group">
<label for="editExactExpirationDate">Expiration Date</label>
<input type="date" id="editExactExpirationDate" name="exact_expiration_date" class="form-control">
</div>
</div>
<div class="form-group">
<label for="editPurchasePrice">Purchase Price (Optional)</label>
<div class="price-input-wrapper">
@@ -623,5 +645,37 @@
<script src="auth.js"></script>
<script src="status.js" defer></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://github.com/sassanix/warracker" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
// Apply footer styles based on theme
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' ||
document.documentElement.classList.contains('dark-mode') ||
document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const observer = new MutationObserver(applyFooterStyles);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>

View File

@@ -325,7 +325,7 @@
row.className = statusClass;
row.innerHTML = `
<td>${escapeHTML(warranty.product_name)}</td>
<td title="${escapeHTML(warranty.product_name)}">${escapeHTML(warranty.product_name)}</td>
<td>${warranty.purchase_date ? formatDateYYYYMMDD(new Date(warranty.purchase_date)) : 'N/A'}</td>
<td>${warranty.is_lifetime ? 'Lifetime' : (warranty.expiration_date ? formatDateYYYYMMDD(new Date(warranty.expiration_date)) : 'N/A')}</td>
<td><span class="${statusClass}">${statusText}</span></td>

View File

@@ -3676,3 +3676,116 @@ input.invalid {
gap: 10px;
align-items: center;
}
/* Style for the warranty duration group when hidden (due to lifetime checkbox) */
#warrantyDurationFields.hidden {
display: none;
}
/* Warranty Entry Method Styling */
.warranty-method-options {
display: flex !important;
flex-direction: row !important;
gap: 20px;
margin-top: 8px;
flex-wrap: wrap;
}
.warranty-method-options .radio-option {
display: flex !important;
flex-direction: row !important;
align-items: center;
cursor: pointer;
font-weight: normal;
margin: 0;
white-space: nowrap;
}
.warranty-method-options .radio-option input[type="radio"] {
margin-right: 8px;
cursor: pointer;
flex-shrink: 0;
}
.warranty-method-options .radio-option span {
user-select: none;
cursor: pointer;
}
/* Ensure both add and edit modal warranty methods display horizontally */
#warrantyEntryMethod .warranty-method-options,
#editWarrantyEntryMethod .warranty-method-options {
display: flex !important;
flex-direction: row !important;
gap: 20px;
flex-wrap: wrap;
align-items: center;
}
/* Mobile responsive - stack vertically only on very small screens if needed */
@media (max-width: 480px) {
.warranty-method-options {
flex-direction: column !important;
gap: 10px;
}
.warranty-method-options .radio-option {
justify-content: flex-start;
}
}
/* Powered by Warracker footer */
.warracker-footer {
margin-top: 50px;
padding: 20px;
text-align: center;
border-top: 1px solid #e0e0e0;
background-color: var(--card-bg) !important;
color: var(--text-color);
font-size: 0.9rem;
}
.warracker-footer a {
color: #3498db;
text-decoration: none;
font-weight: 500;
}
.warracker-footer a:hover {
color: #2980b9;
text-decoration: underline;
}
/* Dark theme styles */
[data-theme="dark"] .warracker-footer {
border-top-color: #444;
}
[data-theme="dark"] .warracker-footer a {
color: #4dabf7;
}
[data-theme="dark"] .warracker-footer a:hover {
color: #339af0;
}
/* Also support html.dark-mode for older implementations */
html.dark-mode .warracker-footer {
border-top-color: #444;
}
html.dark-mode .warracker-footer a {
color: #4dabf7;
}
html.dark-mode .warracker-footer a:hover {
color: #339af0;
}
@media (max-width: 768px) {
.warracker-footer {
margin-top: 30px;
padding: 15px;
font-size: 0.85rem;
}
}

View File

@@ -1,6 +1,6 @@
// Version checker for Warracker
document.addEventListener('DOMContentLoaded', () => {
const currentVersion = '0.9.9.8'; // Current version of the application
const currentVersion = '0.9.9.9'; // Current version of the application
const updateStatus = document.getElementById('updateStatus');
const updateLink = document.getElementById('updateLink');

View File

@@ -31,6 +31,8 @@ server {
image/svg+xml svg svgz;
application/pdf pdf;
image/x-icon ico;
application/json json;
application/manifest+json webmanifest manifest;
}
# API requests - proxy to backend (fixed upstream host)
@@ -46,6 +48,17 @@ server {
# Pass Authorization header to backend
proxy_set_header Authorization $http_authorization;
# Enhanced proxy settings for file handling
proxy_buffering off; # Disable buffering for file downloads to prevent content-length mismatches
proxy_request_buffering off; # Disable request buffering for uploads
proxy_read_timeout 300s; # Increased timeout for large file transfers
proxy_connect_timeout 30s;
proxy_send_timeout 300s;
# Prevent proxy from modifying response headers that could cause issues
proxy_set_header Connection "";
proxy_http_version 1.1;
# Add debug headers to see what's happening
add_header X-Debug-Message "API request proxied to backend" always;
@@ -98,6 +111,21 @@ server {
add_header Content-Type text/plain;
return 200 "Uploads directory exists: $document_root\n";
}
# Specific handling for manifest.json
location = /manifest.json {
alias /var/www/html/manifest.json; # Serve from this specific path
# The global types block should set the correct Content-Type
# (application/manifest+json for 'manifest' extension)
# If not found by alias, it will result in a 404, which is correct.
# No need for try_files here if alias is used and file must exist.
# If you want to be explicit about 404 if alias target doesn't exist:
# if (!-f /var/www/html/manifest.json) { return 404; }
# However, alias itself should handle this.
# Add caching headers if desired, e.g.:
# expires 1d;
# add_header Cache-Control "public, must-revalidate";
}
# Default location
location / {