├── .gitattributes ├── .github └── FUNDING.yml ├── CHANGELOG.md ├── Docker ├── .env.example └── docker-compose.yml ├── Dockerfile ├── LICENSE ├── README.md ├── backend ├── __init__.py ├── __pycache__ │ └── app.cpython-39.pyc ├── app.py ├── auth_utils.py ├── db_handler.py ├── extensions.py ├── fix_permissions.py ├── fix_permissions.sql ├── gunicorn_config.py ├── migrations │ ├── 000_create_warranties_table.sql │ ├── 001_add_serial_numbers.sql │ ├── 002_add_purchase_price.sql │ ├── 003_add_users_table.sql │ ├── 004_create_user_preferences_table.py │ ├── 005_add_expiring_soon_days_column.py │ ├── 006_add_currency_symbol_column.py │ ├── 007_add_notification_preferences.sql │ ├── 008_add_tags.sql │ ├── 009_add_admin_flag_to_tags.sql │ ├── 010_configure_admin_roles.sql │ ├── 011_ensure_admin_permissions.sql │ ├── 012_add_timezone_column.sql │ ├── 013_add_lifetime_warranty.sql │ ├── 014_add_updated_at_to_warranties.sql │ ├── 015_allow_fractional_warranty_years.sql │ ├── 016_add_updated_at_to_tags.sql │ ├── 017_add_notes_to_warranties.sql │ ├── 018_add_vendor_to_warranties.sql │ ├── 019_add_date_format_column.sql │ ├── 020_add_user_id_to_tags.sql │ ├── 021_change_warranty_duration_to_components.sql │ ├── 022_add_other_document_path.sql │ ├── 023_add_oidc_columns_to_users.sql │ ├── 024_fix_tags_constraint.sql │ └── apply_migrations.py ├── oidc_handler.py └── requirements.txt ├── docker-compose.yml ├── frontend ├── about.html ├── auth-new.js ├── auth-redirect.html ├── auth-redirect.js ├── auth.js ├── chart.js ├── favicon.ico ├── file-utils.js ├── fix-auth-buttons-loader.js ├── fix-auth-buttons.js ├── header-fix.css ├── img │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon-512x512.png ├── include-auth-new.js ├── index.html ├── login.html ├── manifest.json ├── mobile-header.css ├── register.html ├── registration-status.js ├── reset-password-request.html ├── reset-password.html ├── script.js ├── settings-new.html ├── settings-new.js ├── settings-styles.css ├── status.html ├── status.js ├── style.css ├── styles.css ├── sw.js ├── theme-loader.js └── version-checker.js ├── images └── demo.gif └── nginx.conf /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: sassanix -------------------------------------------------------------------------------- /Docker/.env.example: -------------------------------------------------------------------------------- 1 | # Warracker Environment Variables Configuration 2 | # Copy this file to .env and customize the values for your deployment 3 | 4 | 5 | ### ** Database Configuration** 6 | 7 | # Database connection settings 8 | DB_HOST=warrackerdb 9 | DB_NAME=warranty_db 10 | DB_USER=warranty_user 11 | DB_PASSWORD=warranty_password 12 | 13 | # Database admin credentials (used for migrations and setup) 14 | DB_ADMIN_USER=warracker_admin 15 | DB_ADMIN_PASSWORD=change_this_password_in_production 16 | 17 | # PostgreSQL-specific settings (for the database container) warrackerdb 18 | POSTGRES_DB=warranty_db 19 | POSTGRES_USER=warranty_user 20 | POSTGRES_PASSWORD=warranty_password 21 | 22 | 23 | ### Security Configuration** 24 | 25 | # Application secret key for JWT tokens and Flask sessions 26 | # IMPORTANT: Generate a strong, unique secret key for production! 27 | SECRET_KEY=your_very_secret_flask_key_change_me 28 | 29 | # JWT token expiration time (in hours) 30 | JWT_EXPIRATION_HOURS=24 31 | 32 | 33 | ### Email/SMTP Configuration** 34 | 35 | # SMTP server settings for sending notifications and password resets 36 | SMTP_HOST=smtp.gmail.com 37 | SMTP_PORT=587 38 | SMTP_USERNAME=youremail@gmail.com 39 | SMTP_PASSWORD=your_email_password 40 | 41 | # Optional SMTP settings 42 | SMTP_USE_TLS=true 43 | SMTP_USE_SSL=false 44 | SMTP_SENDER_EMAIL=noreply@warracker.com 45 | 46 | 47 | ### **URL Configuration** 48 | 49 | # Frontend URL (used for redirects and email links) 50 | # IMPORTANT: Must match your public-facing URL for OIDC and email links to work 51 | FRONTEND_URL=http://localhost:8005 52 | 53 | # Application base URL (used for links in emails and redirects) 54 | APP_BASE_URL=http://localhost:8005 55 | 56 | 57 | ### **File Upload Configuration** 58 | 59 | # Maximum file upload size in megabytes 60 | MAX_UPLOAD_MB=32 61 | 62 | # Nginx maximum body size (should match or exceed MAX_UPLOAD_MB) 63 | NGINX_MAX_BODY_SIZE_VALUE=32M 64 | 65 | 66 | ### **Performance & Memory Configuration** 67 | 68 | # Memory optimization mode 69 | # Options: optimized (default), ultra-light, performance 70 | # - optimized: 2 workers, ~60-80MB RAM usage (recommended for most deployments) 71 | # - ultra-light: 1 worker, ~40-50MB RAM usage (for very limited resources) 72 | # - performance: 4 workers, ~150-200MB RAM usage (for high-traffic deployments) 73 | WARRACKER_MEMORY_MODE=optimized 74 | 75 | 76 | ### **OIDC/SSO Configuration (Optional)** 77 | 78 | # Enable/disable OIDC SSO functionality 79 | OIDC_ENABLED=false 80 | 81 | # OIDC Provider settings 82 | # Provider name (affects button branding: google, github, microsoft, keycloak, etc.) 83 | OIDC_PROVIDER_NAME=oidc 84 | 85 | # OIDC client credentials (obtain from your OIDC provider) 86 | OIDC_CLIENT_ID= 87 | OIDC_CLIENT_SECRET= 88 | 89 | # OIDC issuer URL (e.g., https://accounts.google.com) 90 | OIDC_ISSUER_URL= 91 | 92 | # OIDC scope (space-separated list of scopes) 93 | OIDC_SCOPE=openid email profile 94 | 95 | ### **Development/Debugging Configuration (Optional)** 96 | 97 | # Flask environment (development/production) 98 | FLASK_ENV=production 99 | 100 | # Flask debug mode (true/false) 101 | FLASK_DEBUG=false 102 | 103 | # Flask run port (for development) 104 | FLASK_RUN_PORT=5000 105 | 106 | # Python unbuffered output (helpful for Docker logs) 107 | PYTHONUNBUFFERED=1 108 | 109 | 110 | ### **Example Configurations** 111 | 112 | **Gmail SMTP:** 113 | ```bash 114 | SMTP_HOST=smtp.gmail.com 115 | SMTP_PORT=587 116 | SMTP_USERNAME=youremail@gmail.com 117 | SMTP_PASSWORD=your_app_password 118 | SMTP_USE_TLS=true 119 | ``` 120 | 121 | **Google OIDC:** 122 | ```bash 123 | OIDC_ENABLED=true 124 | OIDC_PROVIDER_NAME=google 125 | OIDC_CLIENT_ID=your_google_client_id.apps.googleusercontent.com 126 | OIDC_CLIENT_SECRET=your_google_client_secret 127 | OIDC_ISSUER_URL=https://accounts.google.com 128 | OIDC_SCOPE=openid email profile 129 | ``` 130 | 131 | **Production deployment:** 132 | ```bash 133 | SECRET_KEY=super_long_random_string_generated_securely 134 | DB_PASSWORD=strong_database_password_123 135 | DB_ADMIN_PASSWORD=different_strong_admin_password_456 136 | FRONTEND_URL=https://warracker.yourdomain.com 137 | APP_BASE_URL=https://warracker.yourdomain.com 138 | SMTP_HOST=smtp.yourdomain.com 139 | SMTP_USERNAME=warracker@yourdomain.com 140 | MAX_UPLOAD_MB=64 141 | NGINX_MAX_BODY_SIZE_VALUE=64M 142 | WARRACKER_MEMORY_MODE=performance 143 | ``` 144 | 145 | ## **How to Use** 146 | 147 | 1. **Copy this configuration** into a file named `.env` in your Docker folder 148 | 2. **Customize the values** according to your specific deployment needs 149 | 3. **Generate strong passwords** for production use, especially for `SECRET_KEY`, `DB_PASSWORD`, and `DB_ADMIN_PASSWORD` 150 | 4. **Set your domain URLs** correctly for `FRONTEND_URL` and `APP_BASE_URL` if deploying publicly 151 | 5. **Configure SMTP** if you want email functionality for password resets and notifications 152 | 6. **Set up OIDC/SSO** if you want single sign-on capabilities 153 | -------------------------------------------------------------------------------- /Docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | warracker: 3 | image: ghcr.io/sassanix/warracker/main:latest 4 | ports: 5 | - "8005:80" 6 | volumes: 7 | - warracker_uploads:/data/uploads 8 | environment: 9 | - DB_HOST=warrackerdb 10 | - DB_NAME=warranty_db 11 | - DB_USER=warranty_user 12 | - DB_PASSWORD=${DB_PASSWORD:-warranty_password} 13 | - SMTP_HOST=smtp.email.com 14 | - SMTP_PORT=465 15 | - SMTP_USERNAME=youremail@email.com 16 | - SMTP_PASSWORD=password 17 | - SECRET_KEY=${APP_SECRET_KEY:-your_strong_default_secret_key_here} 18 | - MAX_UPLOAD_MB=32 # Example: Set max upload size to 32MB 19 | - NGINX_MAX_BODY_SIZE_VALUE=32M # For Nginx, ensure this matches MAX_UPLOAD_MB in concept (e.g., 32M) 20 | # OIDC SSO Configuration (User needs to set these based on their OIDC provider) 21 | - OIDC_PROVIDER_NAME=${OIDC_PROVIDER_NAME:-oidc} 22 | - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} # e.g., your_oidc_client_id_from_provider 23 | - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} # e.g., your_oidc_client_secret_from_provider 24 | - OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-} # e.g., https://your-oidc-provider.com/auth/realms/your-realm 25 | - OIDC_SCOPE=${OIDC_SCOPE:-openid email profile} 26 | # URL settings (Important for OIDC redirects and email links) 27 | # Ensure these point to the public-facing URL of your application 28 | - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8005} 29 | - APP_BASE_URL=${APP_BASE_URL:-http://localhost:8005} 30 | # Memory optimization settings 31 | - WARRACKER_MEMORY_MODE=${WARRACKER_MEMORY_MODE:-optimized} # Options: optimized (default), performance, ultra-light 32 | - PYTHONUNBUFFERED=1 33 | # - FLASK_DEBUG=0 34 | depends_on: 35 | warrackerdb: 36 | condition: service_healthy 37 | restart: unless-stopped 38 | 39 | warrackerdb: 40 | image: postgres:15-alpine 41 | volumes: 42 | - postgres_data:/var/lib/postgresql/data 43 | environment: 44 | - POSTGRES_DB=warranty_db 45 | - POSTGRES_USER=warranty_user 46 | - POSTGRES_PASSWORD=${DB_PASSWORD:-warranty_password} 47 | restart: unless-stopped 48 | 49 | healthcheck: 50 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] 51 | interval: 5s 52 | timeout: 5s 53 | retries: 5 54 | 55 | volumes: 56 | postgres_data: 57 | warracker_uploads: 58 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Start with Python base image 2 | FROM python:3.12-slim-bookworm 3 | 4 | # Install build tools, dev headers, nginx, etc. 5 | RUN apt-get update && \ 6 | apt-get install -y --no-install-recommends \ 7 | build-essential \ 8 | libpq-dev \ 9 | nginx \ 10 | curl \ 11 | postgresql-client \ 12 | supervisor \ 13 | gettext-base && \ 14 | apt-get clean && \ 15 | rm -rf /var/lib/apt/lists/* 16 | # (build-essential = C compiler + tools) 17 | # (libpq-dev = provides pg_config for psycopg2) 18 | 19 | 20 | # Nginx will be started with "daemon off;" via supervisor, so no need to sed the main nginx.conf 21 | 22 | WORKDIR /app 23 | 24 | # (Optional) Upgrade pip to latest 25 | RUN pip install --no-cache-dir --upgrade pip 26 | 27 | # Install Python dependencies 28 | COPY backend/requirements.txt . 29 | RUN pip install --no-cache-dir -r requirements.txt 30 | 31 | # Copy main application and config files to /app 32 | COPY backend/app.py . 33 | COPY backend/gunicorn_config.py . 34 | 35 | # Create the backend package directory in /app and copy modules into it 36 | RUN mkdir -p /app/backend 37 | COPY backend/__init__.py /app/backend/ 38 | COPY backend/auth_utils.py /app/backend/ 39 | COPY backend/db_handler.py /app/backend/ 40 | COPY backend/extensions.py /app/backend/ 41 | COPY backend/oidc_handler.py /app/backend/ 42 | 43 | # Copy other utility scripts and migrations 44 | COPY backend/fix_permissions.py . 45 | COPY backend/fix_permissions.sql . 46 | COPY backend/migrations/ /app/migrations/ 47 | 48 | # Copy frontend files 49 | COPY frontend/*.html /var/www/html/ 50 | COPY frontend/*.js /var/www/html/ 51 | COPY frontend/*.css /var/www/html/ 52 | COPY frontend/manifest.json /var/www/html/manifest.json 53 | # Add favicon and images 54 | COPY frontend/favicon.ico /var/www/html/ 55 | COPY frontend/img/ /var/www/html/img/ 56 | 57 | # Configure nginx site 58 | RUN rm /etc/nginx/sites-enabled/default 59 | # Copy nginx.conf as a template 60 | COPY nginx.conf /etc/nginx/conf.d/default.conf.template 61 | 62 | # Create startup script with database initialization 63 | RUN echo '#!/bin/bash\n\ 64 | set -e # Exit immediately if a command exits with a non-zero status.\n\ 65 | echo "Running database migrations..."\n\ 66 | python /app/migrations/apply_migrations.py\n\ 67 | echo "Ensuring admin role has proper permissions..."\n\ 68 | # Retry logic for granting superuser privileges\n\ 69 | max_attempts=5\n\ 70 | attempt=0\n\ 71 | while [ $attempt -lt $max_attempts ]; do\n\ 72 | echo "Attempt $((attempt+1)) to grant superuser privileges..."\n\ 73 | # Ensure DB variables are set (you might pass these at runtime)\n\ 74 | if [ -z "$DB_PASSWORD" ] || [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then\n\ 75 | echo "Error: Database connection variables (DB_PASSWORD, DB_HOST, DB_USER, DB_NAME) are not set."\n\ 76 | exit 1\n\ 77 | fi\n\ 78 | # Use timeout to prevent indefinite hanging if DB is not ready\n\ 79 | if PGPASSWORD=$DB_PASSWORD psql -w -h $DB_HOST -U $DB_USER -d $DB_NAME -c "ALTER ROLE $DB_USER WITH SUPERUSER;" 2>/dev/null; then\n\ 80 | echo "Successfully granted superuser privileges to $DB_USER"\n\ 81 | break\n\ 82 | else\n\ 83 | echo "Failed to grant privileges (attempt $((attempt+1))), retrying in 5 seconds..."\n\ 84 | sleep 5\n\ 85 | attempt=$((attempt+1))\n\ 86 | fi\n\ 87 | done\n\ 88 | if [ $attempt -eq $max_attempts ]; then\n\ 89 | echo "Error: Failed to grant superuser privileges after $max_attempts attempts."\n\ 90 | exit 1 # Exit if granting fails after retries\n\ 91 | fi\n\ 92 | echo "Running fix permissions script..."\n\ 93 | python /app/fix_permissions.py\n\ 94 | echo "Setup script finished successfully."\n\ 95 | # The actual services (gunicorn, nginx) will be started by Supervisor below\n\ 96 | exit 0 # Exit successfully, Supervisor takes over\n\ 97 | ' > /app/start.sh && chmod +x /app/start.sh 98 | 99 | # Create a wrapper script for starting Nginx with sed for placeholder replacement 100 | RUN echo '#!/bin/sh' > /app/start_nginx_wrapper.sh && \ 101 | echo 'set -e' >> /app/start_nginx_wrapper.sh && \ 102 | echo '' >> /app/start_nginx_wrapper.sh && \ 103 | echo '# Read the environment variable, which the user sets in docker-compose.yml' >> /app/start_nginx_wrapper.sh && \ 104 | echo 'EFFECTIVE_SIZE="${NGINX_MAX_BODY_SIZE_VALUE}"' >> /app/start_nginx_wrapper.sh && \ 105 | echo '' >> /app/start_nginx_wrapper.sh && \ 106 | echo '# Validate EFFECTIVE_SIZE or set default' >> /app/start_nginx_wrapper.sh && \ 107 | echo 'if ! echo "${EFFECTIVE_SIZE}" | grep -Eq "^[0-9]+[mMkKgG]?$"; then' >> /app/start_nginx_wrapper.sh && \ 108 | echo " echo \"Warning: NGINX_MAX_BODY_SIZE_VALUE ('\${EFFECTIVE_SIZE}') is invalid or empty. Defaulting to 32M.\"" >> /app/start_nginx_wrapper.sh && \ 109 | echo " EFFECTIVE_SIZE='32M'" >> /app/start_nginx_wrapper.sh && \ 110 | echo 'fi' >> /app/start_nginx_wrapper.sh && \ 111 | echo '' >> /app/start_nginx_wrapper.sh && \ 112 | echo '# Substitute the placeholder in the template file with the effective size' >> /app/start_nginx_wrapper.sh && \ 113 | echo '# Using | as sed delimiter to avoid issues if EFFECTIVE_SIZE somehow contained /' >> /app/start_nginx_wrapper.sh && \ 114 | echo "sed \"s|__NGINX_MAX_BODY_SIZE_CONFIG_VALUE__|\${EFFECTIVE_SIZE}|g\" /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf" >> /app/start_nginx_wrapper.sh && \ 115 | echo '' >> /app/start_nginx_wrapper.sh && \ 116 | echo "# Print the processed config for debugging" >> /app/start_nginx_wrapper.sh && \ 117 | echo "echo '--- Start of Processed Nginx default.conf ---'" >> /app/start_nginx_wrapper.sh && \ 118 | echo "cat /etc/nginx/conf.d/default.conf" >> /app/start_nginx_wrapper.sh && \ 119 | echo "echo '--- End of Processed Nginx default.conf ---'" >> /app/start_nginx_wrapper.sh && \ 120 | echo '' >> /app/start_nginx_wrapper.sh && \ 121 | echo "# Execute Nginx" >> /app/start_nginx_wrapper.sh && \ 122 | echo "exec /usr/sbin/nginx -g 'daemon off;'" >> /app/start_nginx_wrapper.sh && \ 123 | chmod +x /app/start_nginx_wrapper.sh 124 | 125 | # REMOVED: The RUN echo command that overwrites the nginx.conf, as we now use a template. 126 | 127 | # Expose port 128 | EXPOSE 80 129 | 130 | # Define health check 131 | HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ 132 | CMD curl -f http://localhost/ || exit 1 133 | 134 | # Set environment variables 135 | ENV PYTHONUNBUFFERED=1 136 | 137 | # Create supervisor log directory 138 | RUN mkdir -p /var/log/supervisor 139 | 140 | # Create supervisor configuration 141 | # Using Heredoc for cleaner multiline config 142 | COPY < 3 | 4 | 5 | 6 |

Warracker

7 |

8 | Open-source warranty tracker for individuals and teams.
9 | The easiest way to organize product warranties, monitor expiration dates, and store receipts or related documents. 10 | 11 |

12 | 13 | 14 | 15 |
16 | 17 | ![GitHub issues](https://img.shields.io/github/issues/sassanix/Warracker) 18 | ![GitHub license](https://img.shields.io/github/license/sassanix/Warracker) 19 | ![GitHub last commit](https://img.shields.io/github/last-commit/sassanix/Warracker) 20 | ![GitHub release](https://img.shields.io/github/v/release/sassanix/Warracker) 21 | ![GitHub contributors](https://img.shields.io/github/contributors/sassanix/Warracker) 22 | [![](https://dcbadge.limes.pink/api/server/PGxVS3U2Nw?style=flat)](https://discord.gg/PGxVS3U2Nw) 23 | 24 |

25 | Warracker Demo 26 |

27 | 28 | 29 | # 30 | 31 |
32 | 33 | ## 🌟Overview 34 | 35 | **Warracker** is a web-based application that simplifies the management of product warranties. It allows users to organize warranty information, monitor expiration dates, and securely store related documents. 36 | 37 | ## Features 38 | 39 | * **Centralized Management**: Track all your product warranties in one place. 40 | * **Detailed Records**: Store purchase dates, durations, and notes. 41 | * **Document Storage**: Upload receipts, invoices, and manuals. 42 | * **Proactive Alerts**: Receive notifications for upcoming expirations. 43 | * **Quick Search and Filter**: Find warranties by product name, serial number, vendor, tags, or notes. 44 | * **Multi-User Support**: Create multiple user accounts with admin controls. 45 | * **Data Export/Import**: Export to CSV or import from CSV files. 46 | * **Email Notifications**: Get timely reminders about expirations. 47 | * **Customizable Settings**: Adjust currency symbols and date formats. 48 | * **Tagging**: Organize warranties with flexible tags. 49 | * **Password Reset**: Secure, token-based account recovery. 50 | * **OIDC Single Sign-On (SSO)**: Seamlessly log in using identity providers like Google, GitHub, or Keycloak. 51 | 52 | ## Project Status 53 | 54 | **Warracker is in active beta.** 55 | The essential features are reliable and ready for everyday use. Development is ongoing, with regular updates and improvements. 56 | 57 | * ✅ Stable core for tracking, notification , and managing warranty documents, files 58 | * ✅ Full support for self-hosted deployments 59 | * ⚒️ Advanced enhancements are still being worked on 60 | * ✍️ Your feedback and bug reports help shape the future of the app 61 | 62 | ## Screenshots 63 | 64 | **Home Page** 65 | 66 | ![image](https://github.com/user-attachments/assets/fa491480-4c75-4a1a-91d0-741141ca0183) 67 | 68 | 69 | ![image](https://github.com/user-attachments/assets/67662203-b25b-46e5-920d-082537a02d42) 70 | 71 | **Status Dashboard** 72 | 73 | ![image](https://github.com/user-attachments/assets/4c938b33-d6be-4787-a2d9-9153b0234ee2) 74 | 75 | ## Technology Stack 76 | 77 | * **Frontend**: HTML, CSS, JavaScript 78 | * **Backend**: Python with Flask 79 | * **Database**: PostgreSQL 80 | * **Containerization**: Docker and Docker Compose 81 | * **Web Server**: Nginx 82 | 83 | ## Roadmap 84 | 85 | * ✅ User Authentication 86 | * ✅ Settings Page 87 | * ✅ Status Page 88 | * ✅ Customizable Reminders 89 | * ✅ Email Notifications 90 | * ✅ Warranty Categories via Tags 91 | * ✅ CSV Import/Export 92 | * ✅ OIDC SSO Functionality 93 | * [ ] Warranty Claim Tracking 94 | * [ ] Calendar Integration 95 | * [ ] Advanced User/Admin Controls 96 | * [ ] Localization Support 97 | 98 | ## Setup 99 | 100 | ### Prerequisites 101 | 102 | * Docker and Docker Compose installed on your system. 103 | 104 | ## 🐋Pull Docker 105 | 106 | ``` 107 | services: 108 | warracker: 109 | image: ghcr.io/sassanix/warracker/main:latest 110 | ports: 111 | - "8005:80" 112 | volumes: 113 | - warracker_uploads:/data/uploads 114 | env_file: 115 | - .env 116 | depends_on: 117 | warrackerdb: 118 | condition: service_healthy 119 | restart: unless-stopped 120 | 121 | warrackerdb: 122 | image: postgres:15-alpine 123 | volumes: 124 | - postgres_data:/var/lib/postgresql/data 125 | env_file: 126 | - .env 127 | restart: unless-stopped 128 | healthcheck: 129 | test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] 130 | interval: 5s 131 | timeout: 5s 132 | retries: 5 133 | 134 | volumes: 135 | postgres_data: 136 | warracker_uploads: 137 | ``` 138 | 139 | To get the docker compose file with environemts and .env example for warracker and the warrackerdb please go [here](https://github.com/sassanix/Warracker/tree/main/Docker) 140 | 141 | ## Usage 142 | 143 | ### Adding a Warranty 144 | 145 | 1. Fill in the product details by clicking on add warranty. 146 | 2. Enter the purchase date and warranty duration. 147 | 3. Optionally upload receipt/documentation. 148 | 4. Click the "Add Warranty" button. 149 | 150 | ### Managing Warranties 151 | 152 | * Use the search box to filter warranties. 153 | * Click the edit icon to modify warranty details. 154 | * Click the delete icon to remove a warranty. 155 | 156 | ## Product Information Entry Requirements for CSV import 157 | 158 | | Field Name | Format / Example | Required? | Notes | 159 | |----------------|-------------------------------------------|--------------------------------------------------------|-----------------------------------------------------------------------| 160 | | **ProductName** | Text | ✅ Yes | Provide the name of the product. | 161 | | **PurchaseDate** | Date (`YYYY-MM-DD`, e.g., `2024-05-21`) | ✅ Yes | Use ISO format only. | 162 | | **WarrantyDurationYears** | Whole Number (`0`, `1`, `5`) | ✅ Yes, if `IsLifetime` is `FALSE` and Months/Days are 0/blank. At least one duration field (Years, Months, Days) must be non-zero if not lifetime. | Represents the years part of the warranty. Can be combined with Months and Days. | 163 | | **WarrantyDurationMonths** | Whole Number (`0`, `6`, `18`) | ✅ Yes, if `IsLifetime` is `FALSE` and Years/Days are 0/blank. At least one duration field (Years, Months, Days) must be non-zero if not lifetime. | Represents the months part of the warranty. Can be combined with Years and Days. Max 11 if Years also provided. | 164 | | **WarrantyDurationDays** | Whole Number (`0`, `15`, `90`) | ✅ Yes, if `IsLifetime` is `FALSE` and Years/Months are 0/blank. At least one duration field (Years, Months, Days) must be non-zero if not lifetime. | Represents the days part of the warranty. Can be combined with Years and Months. Max 29/30 if Months also provided. | 165 | | **IsLifetime** | `TRUE` or `FALSE` (case-insensitive) | ❌ No (Optional) | If omitted, defaults to `FALSE`. If `TRUE`, duration fields are ignored. | 166 | | **PurchasePrice** | Number (`199.99`, `50`) | ❌ No (Optional) | Cannot be negative if provided. | 167 | | **SerialNumber** | Text (`SN123`, `SN123,SN456`) | ❌ No (Optional) | For multiple values, separate with commas. | 168 | | **ProductURL** | Text (URL format) | ❌ No (Optional) | Full URL to product page (optional field). https://producturl.com | 169 | | **Vendor** | Text | ❌ No (Optional) | Name of the vendor or seller where the product was purchased. | 170 | | **Tags** | Text (`tag1,tag2`) | ❌ No (Optional) | Use comma-separated values for multiple tags. | 171 | 172 | 173 | ## Why I Built This 174 | 175 | Warracker was born from personal frustration with warranty confusion. When my father’s dishwasher broke, we had the invoice and assumed it was under warranty, only to find out we were referencing the wrong one, and the warranty had ended by a couple of months. 176 | 177 | That experience, along with others like it, made me realize how common and avoidable these issues are. So I built **Warracker**, a simple, organized way to track purchases, receipts, and warranties. It has already saved me money by reminding me to get car repairs done before my warranty expired. 178 | 179 | Inspired by [**Wallos**](https://github.com/ellite/Wallos), I wanted to bring the same clarity to warranties that it brought to subscriptions and share it with anyone who's ever been burned by missed coverage. 180 | 181 | 182 | ## Contributing 183 | 184 | We welcome contributions and appreciate your interest in improving this project! To get started, please follow these steps: 185 | 186 | 187 | ### How to Contribute 188 | 189 | 1. **Fork** the repository. 190 | 2. **Create a branch** for your changes: 191 | `git checkout -b feature/amazing-feature` 192 | 3. **Commit** your changes: 193 | `git commit -m "Add: amazing feature"` 194 | 4. **Push** to your forked repository: 195 | `git push origin feature/amazing-feature` 196 | 5. **Open a Pull Request** with a clear explanation of your changes. 197 | 198 | ### Contribution Guidelines 199 | 200 | * **Start with an issue**: Before submitting a Pull Request, ensure the change has been discussed in an issue. 201 | * **Help is welcome**: Check the [issues](../../issues) for open discussions or areas where help is needed. 202 | * **Keep it focused**: Each Pull Request should focus on a single change or feature. 203 | * **Follow project style**: Match the project's code style and naming conventions. 204 | * **Be respectful**: We value inclusive and constructive collaboration. 205 | 206 | ### 🤝 Contributors: 207 | 208 | [](https://github.com/sassanix) 209 | [](https://github.com/humrochagf) 210 | [](https://github.com/clmcavaney) 211 | 212 | ### ❤️ Supporters: 213 | 214 | [](https://github.com/SirSpidey) 215 | [](https://github.com/keithellis74) 216 | 217 | 218 | 219 | [![Support Warracker](https://img.shields.io/badge/Support-Warracker-red?style=for-the-badge&logo=github-sponsors)](https://buymeacoffee.com/sassanix) 220 | 221 | 222 | ## Join Our Community 223 | 224 | [![Join our Discord server!](https://invidget.switchblade.xyz/PGxVS3U2Nw)](https://discord.gg/PGxVS3U2Nw) 225 | 226 | Want to discuss the project or need help? Join our Discord community! 227 | 228 | ## License 229 | 230 | This project is licensed under the GNU Affero General Public License v3.0 - see the [LICENSE](LICENSE) file for details. 231 | 232 | ## Acknowledgements 233 | 234 | * Flask 235 | * PostgreSQL 236 | * Docker 237 | * Chart.js 238 | 239 | 240 | ## Star History 241 | 242 | 243 | 244 | 245 | Star History Chart 246 | 247 | 248 | -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes 'backend' a Python package 2 | -------------------------------------------------------------------------------- /backend/__pycache__/app.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassanix/Warracker/c968d8660c4bb2302b774fb1e9e413a89f9dee10/backend/__pycache__/app.cpython-39.pyc -------------------------------------------------------------------------------- /backend/auth_utils.py: -------------------------------------------------------------------------------- 1 | # backend/auth_utils.py 2 | import jwt 3 | from datetime import datetime, timedelta 4 | from flask import current_app # To access app.config 5 | 6 | def generate_token(user_id): 7 | """Generate a JWT token for the user""" 8 | payload = { 9 | 'exp': datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA'], 10 | 'iat': datetime.utcnow(), 11 | 'sub': user_id 12 | } 13 | return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') 14 | 15 | # Note: You can later move decode_token, token_required, admin_required here too. 16 | # For token_required and admin_required, you'll also need: 17 | # from functools import wraps 18 | # from flask import request, jsonify 19 | # And you'd need to import get_db_connection, release_db_connection from your db_handler. -------------------------------------------------------------------------------- /backend/db_handler.py: -------------------------------------------------------------------------------- 1 | # backend/db_handler.py 2 | import os 3 | import psycopg2 4 | from psycopg2 import pool 5 | import logging 6 | import time 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | # PostgreSQL connection details 11 | DB_HOST = os.environ.get('DB_HOST', 'warrackerdb') 12 | DB_NAME = os.environ.get('DB_NAME', 'warranty_db') 13 | DB_USER = os.environ.get('DB_USER', 'warranty_user') 14 | DB_PASSWORD = os.environ.get('DB_PASSWORD', 'warranty_password') 15 | 16 | connection_pool = None # Global connection pool for this module 17 | 18 | def init_db_pool(max_retries=5, retry_delay=5): 19 | global connection_pool # Ensure we're modifying the global variable in this module 20 | attempt = 0 21 | last_exception = None 22 | 23 | if connection_pool is not None: 24 | logger.info("[DB_HANDLER] Database connection pool already initialized.") 25 | return connection_pool 26 | 27 | while attempt < max_retries: 28 | try: 29 | logger.info(f"[DB_HANDLER] Attempting to initialize database pool (attempt {attempt+1}/{max_retries})") 30 | # Optimized connection pool for memory efficiency 31 | connection_pool = pool.SimpleConnectionPool( 32 | 1, 4, # Reduced from 1,10 to 1,4 for memory efficiency 33 | host=DB_HOST, 34 | database=DB_NAME, 35 | user=DB_USER, 36 | password=DB_PASSWORD, 37 | # Memory optimization settings 38 | connect_timeout=10, # Connection timeout 39 | application_name='warracker_optimized' # Identify connections 40 | ) 41 | logger.info("[DB_HANDLER] Database connection pool initialized successfully.") 42 | return connection_pool # Return the pool for external check if needed 43 | except Exception as e: 44 | last_exception = e 45 | logger.error(f"[DB_HANDLER] Database connection pool initialization error: {e}") 46 | logger.info(f"[DB_HANDLER] Retrying in {retry_delay} seconds...") 47 | time.sleep(retry_delay) 48 | attempt += 1 49 | 50 | logger.error(f"[DB_HANDLER] Failed to initialize database pool after {max_retries} attempts.") 51 | if last_exception: 52 | raise last_exception 53 | else: 54 | raise Exception("Unknown error creating database pool") 55 | 56 | def get_db_connection(): 57 | global connection_pool 58 | if connection_pool is None: 59 | logger.error("[DB_HANDLER] Database connection pool is None. Attempting to re-initialize.") 60 | init_db_pool() # Attempt to initialize it 61 | if connection_pool is None: # If still None after attempt 62 | logger.critical("[DB_HANDLER] CRITICAL: Database pool re-initialization failed.") 63 | raise Exception("Database connection pool is not initialized and could not be re-initialized.") 64 | try: 65 | return connection_pool.getconn() 66 | except Exception as e: 67 | logger.error(f"[DB_HANDLER] Error getting connection from pool: {e}") 68 | raise 69 | 70 | def release_db_connection(conn): 71 | global connection_pool 72 | if connection_pool: 73 | try: 74 | connection_pool.putconn(conn) 75 | except Exception as e: 76 | logger.error(f"[DB_HANDLER] Error releasing connection to pool: {e}. Connection state: {conn.closed if conn else 'N/A'}") 77 | # If putconn fails, the connection might be broken or the pool is in a bad state. 78 | # Attempt to close the connection directly as a fallback. 79 | if conn and not conn.closed: 80 | try: 81 | conn.close() 82 | logger.info("[DB_HANDLER] Connection closed directly after putconn failure.") 83 | except Exception as close_err: 84 | logger.error(f"[DB_HANDLER] Error closing connection directly after putconn failed: {close_err}") 85 | else: 86 | logger.warning("[DB_HANDLER] Connection pool is None, cannot release connection to pool. Attempting to close directly.") 87 | if conn and not conn.closed: 88 | try: 89 | conn.close() 90 | except Exception as e: 91 | logger.error(f"[DB_HANDLER] Error closing connection directly (pool was None): {e}") -------------------------------------------------------------------------------- /backend/extensions.py: -------------------------------------------------------------------------------- 1 | # backend/extensions.py 2 | from authlib.integrations.flask_client import OAuth 3 | 4 | oauth = OAuth() -------------------------------------------------------------------------------- /backend/fix_permissions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | import psycopg2 5 | import logging 6 | import time 7 | 8 | from psycopg2.extensions import AsIs 9 | 10 | # Configure logging 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | # PostgreSQL connection details 15 | DB_HOST = os.environ.get('DB_HOST', 'warrackerdb') 16 | DB_NAME = os.environ.get('DB_NAME', 'warranty_db') 17 | DB_USER = os.environ.get('DB_USER', 'warranty_user') 18 | DB_PASSWORD = os.environ.get('DB_PASSWORD', 'warranty_password') 19 | 20 | def create_db_connection(max_retries=5, retry_delay=5): 21 | """Create a database connection with retry logic""" 22 | attempt = 0 23 | last_exception = None 24 | 25 | while attempt < max_retries: 26 | try: 27 | logger.info(f"Attempting to connect to database (attempt {attempt+1}/{max_retries})") 28 | conn = psycopg2.connect( 29 | host=DB_HOST, 30 | database=DB_NAME, 31 | user=DB_USER, 32 | password=DB_PASSWORD 33 | ) 34 | logger.info("Database connection successful") 35 | return conn 36 | except Exception as e: 37 | last_exception = e 38 | logger.error(f"Database connection error: {e}") 39 | logger.info(f"Retrying in {retry_delay} seconds...") 40 | time.sleep(retry_delay) 41 | attempt += 1 42 | 43 | # If we got here, all connection attempts failed 44 | logger.error(f"Failed to connect to database after {max_retries} attempts") 45 | raise last_exception 46 | 47 | def fix_permissions(): 48 | """Run the fix permissions SQL script""" 49 | conn = None 50 | try: 51 | conn = create_db_connection() 52 | conn.autocommit = True # Important for ALTER ROLE commands 53 | cursor = conn.cursor() 54 | 55 | # Read the fix permissions SQL script 56 | script_path = os.path.join(os.path.dirname(__file__), 'fix_permissions.sql') 57 | with open(script_path, 'r') as f: 58 | sql_script = f.read() 59 | 60 | # Execute the script 61 | logger.info("Executing fix permissions SQL script...") 62 | cursor.execute( 63 | sql_script, 64 | { 65 | "db_name": AsIs(DB_NAME), 66 | "db_user": AsIs(DB_USER), 67 | } 68 | ) 69 | 70 | logger.info("Permissions fixed successfully") 71 | 72 | except Exception as e: 73 | logger.error(f"Error fixing permissions: {e}") 74 | sys.exit(1) 75 | finally: 76 | if conn: 77 | conn.close() 78 | 79 | if __name__ == "__main__": 80 | fix_permissions() 81 | -------------------------------------------------------------------------------- /backend/fix_permissions.sql: -------------------------------------------------------------------------------- 1 | -- Script to fix PostgreSQL permissions for db_user 2 | 3 | -- Grant superuser privileges 4 | ALTER ROLE %(db_user)s WITH SUPERUSER; 5 | 6 | -- Grant role management privileges 7 | ALTER ROLE %(db_user)s WITH CREATEROLE; 8 | 9 | -- Ensure all database objects are accessible 10 | GRANT ALL PRIVILEGES ON DATABASE %(db_name)s TO %(db_user)s; 11 | GRANT ALL PRIVILEGES ON SCHEMA public TO %(db_user)s; 12 | GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO %(db_user)s; 13 | GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO %(db_user)s; 14 | GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO %(db_user)s; 15 | 16 | -- Make db_user the owner of all tables 17 | DO $$ 18 | DECLARE 19 | rec RECORD; 20 | BEGIN 21 | FOR rec IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' 22 | LOOP 23 | EXECUTE 'ALTER TABLE public.' || quote_ident(rec.tablename) || ' OWNER TO %(db_user)s'; 24 | END LOOP; 25 | END $$; 26 | 27 | -- Make db_user the owner of all sequences 28 | DO $$ 29 | DECLARE 30 | rec RECORD; 31 | BEGIN 32 | FOR rec IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' 33 | LOOP 34 | EXECUTE 'ALTER SEQUENCE public.' || quote_ident(rec.sequencename) || ' OWNER TO %(db_user)s'; 35 | END LOOP; 36 | END $$; 37 | 38 | -- Make db_user the owner of all functions 39 | DO $$ 40 | DECLARE 41 | rec RECORD; 42 | BEGIN 43 | FOR rec IN SELECT proname, p.oid FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = 'public' 44 | LOOP 45 | BEGIN 46 | EXECUTE 'ALTER FUNCTION public.' || quote_ident(rec.proname) || '(' || pg_get_function_arguments(rec.oid) || ') OWNER TO %(db_user)s'; 47 | EXCEPTION WHEN OTHERS THEN 48 | RAISE NOTICE 'Error changing ownership of function %%: %%', rec.proname, SQLERRM; 49 | END; 50 | END LOOP; 51 | END $$; 52 | -------------------------------------------------------------------------------- /backend/gunicorn_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Gunicorn configuration file for Warracker application. 6 | Optimized for memory efficiency with configurable modes. 7 | """ 8 | 9 | import os 10 | import multiprocessing 11 | 12 | # Server configurations - Dynamic based on memory mode 13 | bind = "0.0.0.0:5000" 14 | 15 | # Check memory mode from environment variable 16 | memory_mode = os.environ.get('WARRACKER_MEMORY_MODE', 'optimized').lower() 17 | 18 | if memory_mode == 'ultra-light': 19 | # Ultra-lightweight configuration for very memory-constrained environments 20 | workers = 1 # Single worker for minimal memory usage (~40-50MB total) 21 | worker_class = "sync" # Sync worker for lowest memory overhead 22 | worker_connections = 50 # Reduced connections 23 | max_requests = 500 # More frequent worker restarts to prevent memory leaks 24 | worker_rlimit_as = 67108864 # 64MB per worker limit 25 | print("Using ULTRA-LIGHT memory mode - minimal RAM usage, lower concurrency") 26 | elif memory_mode == 'performance': 27 | # High-performance configuration for servers with plenty of RAM 28 | workers = 4 # Original worker count for maximum concurrency 29 | worker_class = "gevent" # Efficient async I/O handling 30 | worker_connections = 200 # Higher connection limit per worker 31 | max_requests = 2000 # Less frequent restarts for better performance 32 | worker_rlimit_as = 268435456 # 256MB per worker limit 33 | print("Using PERFORMANCE memory mode - maximum concurrency and performance") 34 | else: 35 | # Default optimized configuration for balanced performance and memory usage 36 | workers = 2 # Reduced from 4 to save ~75MB RAM 37 | worker_class = "gevent" # More memory efficient than sync workers 38 | worker_connections = 100 # Limit concurrent connections per worker 39 | max_requests = 1000 # Restart workers after handling requests to prevent memory leaks 40 | worker_rlimit_as = 134217728 # 128MB per worker limit 41 | print("Using OPTIMIZED memory mode - balanced RAM usage and performance") 42 | 43 | # Common settings for both modes 44 | timeout = 120 45 | keepalive = 5 46 | max_requests_jitter = 50 # Add randomness to prevent thundering herd 47 | 48 | # Enhanced settings for file handling to prevent Content-Length mismatches 49 | limit_request_line = 8190 # Increase request line limit 50 | limit_request_fields = 200 # Increase header fields limit 51 | limit_request_field_size = 8190 # Increase header field size limit 52 | 53 | # Memory management (common to both modes) 54 | preload_app = True # Share memory between workers (saves RAM) 55 | worker_tmp_dir = "/dev/shm" # Use RAM disk for worker temporary files 56 | 57 | # Process management callbacks 58 | def worker_int(worker): 59 | """Called just after a worker exited on SIGINT or SIGQUIT.""" 60 | print(f"Worker {worker.pid} received SIGINT/SIGQUIT") 61 | 62 | def worker_abort(worker): 63 | """Called when a worker receives the SIGABRT signal.""" 64 | print(f"Worker {worker.pid} received SIGABRT") 65 | 66 | def worker_exit(server, worker): 67 | """Called just after a worker has been exited.""" 68 | print(f"Worker {worker.pid} exited") 69 | 70 | def on_starting(server): 71 | """Called just before the master process is initialized.""" 72 | print("Server is starting with memory-optimized configuration") 73 | 74 | def post_fork(server, worker): 75 | """Called just after a worker has been forked.""" 76 | os.environ["GUNICORN_WORKER_ID"] = str(worker.age - 1) 77 | os.environ["GUNICORN_WORKER_PROCESS_NAME"] = f"worker-{worker.age - 1}" 78 | os.environ["GUNICORN_WORKER_CLASS"] = worker_class 79 | 80 | print(f"Worker {worker.pid} (ID: {worker.age - 1}) forked with memory optimization") 81 | 82 | def pre_fork(server, worker): 83 | """Called just before a worker is forked.""" 84 | print(f"Forking worker #{worker.age}") 85 | 86 | print(f"Gunicorn configuration loaded: {workers} {worker_class} workers in {memory_mode.upper()} mode") 87 | print(f"Memory limit per worker: {worker_rlimit_as // 1024 // 1024}MB, Max connections: {worker_connections if 'worker_connections' in locals() else 'N/A'}") 88 | 89 | # To switch memory modes, set WARRACKER_MEMORY_MODE environment variable: 90 | # - "optimized" (default): 2 gevent workers, balanced performance and memory usage (~60-80MB) 91 | # - "ultra-light": 1 sync worker, minimal memory usage (~40-50MB, lower concurrency) 92 | # - "performance": 4 gevent workers, high-performance mode (~200MB) -------------------------------------------------------------------------------- /backend/migrations/000_create_warranties_table.sql: -------------------------------------------------------------------------------- 1 | -- backend/migrations/000_create_warranties_table.sql 2 | 3 | -- Create warranties table if it doesn't exist 4 | CREATE TABLE IF NOT EXISTS warranties ( 5 | id SERIAL PRIMARY KEY, 6 | product_name VARCHAR(255) NOT NULL, 7 | purchase_date DATE NOT NULL, 8 | warranty_years INTEGER NOT NULL, 9 | expiration_date DATE, 10 | invoice_path TEXT, 11 | manual_path TEXT, 12 | product_url TEXT, 13 | notes TEXT, 14 | -- purchase_price is added by migration 002 15 | -- user_id is added by migration 003 16 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 17 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 18 | ); 19 | 20 | -- Create indexes if they don't exist 21 | CREATE INDEX IF NOT EXISTS idx_expiration_date ON warranties(expiration_date); 22 | CREATE INDEX IF NOT EXISTS idx_product_name ON warranties(product_name); -------------------------------------------------------------------------------- /backend/migrations/001_add_serial_numbers.sql: -------------------------------------------------------------------------------- 1 | -- Add serial numbers table 2 | DO $$ 3 | BEGIN 4 | IF NOT EXISTS ( 5 | SELECT 1 FROM information_schema.tables 6 | WHERE table_name = 'serial_numbers' 7 | ) THEN 8 | CREATE TABLE serial_numbers ( 9 | id SERIAL PRIMARY KEY, 10 | warranty_id INTEGER NOT NULL, 11 | serial_number VARCHAR(255) NOT NULL, 12 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 13 | FOREIGN KEY (warranty_id) REFERENCES warranties(id) ON DELETE CASCADE 14 | ); 15 | 16 | -- Create indexes only if we created the table 17 | CREATE INDEX idx_warranty_id ON serial_numbers(warranty_id); 18 | CREATE INDEX idx_serial_number ON serial_numbers(serial_number); 19 | END IF; 20 | END $$; -------------------------------------------------------------------------------- /backend/migrations/002_add_purchase_price.sql: -------------------------------------------------------------------------------- 1 | -- Add purchase_price column to warranties table if it doesn't exist 2 | DO $$ 3 | BEGIN 4 | IF NOT EXISTS ( 5 | SELECT 1 FROM information_schema.columns 6 | WHERE table_name = 'warranties' AND column_name = 'purchase_price' 7 | ) THEN 8 | ALTER TABLE warranties ADD COLUMN purchase_price DECIMAL(10, 2); 9 | END IF; 10 | END $$; -------------------------------------------------------------------------------- /backend/migrations/003_add_users_table.sql: -------------------------------------------------------------------------------- 1 | -- Migration: Add users table and related tables 2 | 3 | -- Users table 4 | CREATE TABLE IF NOT EXISTS users ( 5 | id SERIAL PRIMARY KEY, 6 | username VARCHAR(255) NOT NULL UNIQUE, 7 | email VARCHAR(255) NOT NULL UNIQUE, 8 | password_hash VARCHAR(255) NOT NULL, 9 | first_name VARCHAR(255), 10 | last_name VARCHAR(255), 11 | is_active BOOLEAN DEFAULT TRUE, 12 | is_admin BOOLEAN DEFAULT FALSE, 13 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 14 | last_login TIMESTAMP 15 | ); 16 | 17 | CREATE INDEX IF NOT EXISTS idx_username ON users(username); 18 | CREATE INDEX IF NOT EXISTS idx_email ON users(email); 19 | 20 | -- Add user_id to warranties table if it doesn't exist 21 | ALTER TABLE warranties ADD COLUMN IF NOT EXISTS user_id INTEGER; 22 | 23 | -- Add foreign key constraint if it doesn't exist 24 | DO $$ 25 | BEGIN 26 | IF NOT EXISTS ( 27 | SELECT 1 FROM pg_constraint 28 | WHERE conname = 'fk_user' AND conrelid = 'warranties'::regclass 29 | ) THEN 30 | ALTER TABLE warranties ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; 31 | END IF; 32 | END 33 | $$; 34 | 35 | CREATE INDEX IF NOT EXISTS idx_user_id ON warranties(user_id); 36 | 37 | -- Create password reset tokens table 38 | CREATE TABLE IF NOT EXISTS password_reset_tokens ( 39 | id SERIAL PRIMARY KEY, 40 | user_id INTEGER NOT NULL, 41 | token VARCHAR(255) NOT NULL, 42 | expires_at TIMESTAMP NOT NULL, 43 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 44 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 45 | ); 46 | 47 | CREATE INDEX IF NOT EXISTS idx_token ON password_reset_tokens(token); 48 | CREATE INDEX IF NOT EXISTS idx_user_id_token ON password_reset_tokens(user_id); 49 | 50 | -- Create user sessions table 51 | CREATE TABLE IF NOT EXISTS user_sessions ( 52 | id SERIAL PRIMARY KEY, 53 | user_id INTEGER NOT NULL, 54 | session_token VARCHAR(255) NOT NULL, 55 | expires_at TIMESTAMP NOT NULL, 56 | ip_address VARCHAR(45), 57 | user_agent TEXT, 58 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 59 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 60 | ); 61 | 62 | CREATE INDEX IF NOT EXISTS idx_session_token ON user_sessions(session_token); 63 | CREATE INDEX IF NOT EXISTS idx_user_id_session ON user_sessions(user_id); 64 | -------------------------------------------------------------------------------- /backend/migrations/004_create_user_preferences_table.py: -------------------------------------------------------------------------------- 1 | """ 2 | Migration 004: Add user_preferences table 3 | """ 4 | 5 | def upgrade(cursor): 6 | """ 7 | Add user_preferences table to store user settings 8 | """ 9 | cursor.execute(""" 10 | CREATE TABLE IF NOT EXISTS user_preferences ( 11 | id SERIAL PRIMARY KEY, 12 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 13 | email_notifications BOOLEAN NOT NULL DEFAULT TRUE, 14 | default_view VARCHAR(10) NOT NULL DEFAULT 'grid', 15 | theme VARCHAR(10) NOT NULL DEFAULT 'light', 16 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 17 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 18 | UNIQUE(user_id) 19 | ) 20 | """) 21 | 22 | # Add index for faster lookups 23 | cursor.execute(""" 24 | CREATE INDEX IF NOT EXISTS idx_user_preferences_user_id ON user_preferences(user_id) 25 | """) 26 | 27 | print("Created user_preferences table") 28 | 29 | def downgrade(cursor): 30 | """ 31 | Remove user_preferences table 32 | """ 33 | cursor.execute(""" 34 | DROP TABLE IF EXISTS user_preferences CASCADE 35 | """) 36 | 37 | print("Dropped user_preferences table") -------------------------------------------------------------------------------- /backend/migrations/005_add_expiring_soon_days_column.py: -------------------------------------------------------------------------------- 1 | """ 2 | Migration 007: Add expiring_soon_days to user_preferences 3 | """ 4 | 5 | def upgrade(cursor): 6 | """ 7 | Create user_preferences table if it doesn't exist and 8 | add expiring_soon_days column to user_preferences table to allow customization of 9 | how many days before expiration a warranty should be considered "expiring soon" 10 | """ 11 | # Check if user_preferences table exists 12 | cursor.execute(""" 13 | SELECT EXISTS ( 14 | SELECT FROM information_schema.tables 15 | WHERE table_name = 'user_preferences' 16 | ) 17 | """) 18 | 19 | table_exists = cursor.fetchone()[0] 20 | 21 | if not table_exists: 22 | # Create the user_preferences table 23 | cursor.execute(""" 24 | CREATE TABLE user_preferences ( 25 | id SERIAL PRIMARY KEY, 26 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 27 | email_notifications BOOLEAN NOT NULL DEFAULT TRUE, 28 | default_view VARCHAR(10) NOT NULL DEFAULT 'grid', 29 | theme VARCHAR(10) NOT NULL DEFAULT 'light', 30 | expiring_soon_days INTEGER NOT NULL DEFAULT 30, 31 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 32 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 33 | UNIQUE(user_id) 34 | ) 35 | """) 36 | 37 | # Add index for faster lookups 38 | cursor.execute(""" 39 | CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id) 40 | """) 41 | 42 | print("Created user_preferences table with expiring_soon_days column") 43 | else: 44 | # Add expiring_soon_days column if it doesn't exist 45 | cursor.execute(""" 46 | ALTER TABLE user_preferences 47 | ADD COLUMN IF NOT EXISTS expiring_soon_days INTEGER NOT NULL DEFAULT 30 48 | """) 49 | 50 | print("Added expiring_soon_days column to existing user_preferences table") 51 | 52 | def downgrade(cursor): 53 | """ 54 | Remove expiring_soon_days column from user_preferences 55 | """ 56 | # Check if user_preferences table exists 57 | cursor.execute(""" 58 | SELECT EXISTS ( 59 | SELECT FROM information_schema.tables 60 | WHERE table_name = 'user_preferences' 61 | ) 62 | """) 63 | 64 | table_exists = cursor.fetchone()[0] 65 | 66 | if table_exists: 67 | # Check if column exists 68 | cursor.execute(""" 69 | SELECT EXISTS ( 70 | SELECT FROM information_schema.columns 71 | WHERE table_name = 'user_preferences' AND column_name = 'expiring_soon_days' 72 | ) 73 | """) 74 | 75 | column_exists = cursor.fetchone()[0] 76 | 77 | if column_exists: 78 | cursor.execute(""" 79 | ALTER TABLE user_preferences 80 | DROP COLUMN IF EXISTS expiring_soon_days 81 | """) 82 | 83 | print("Removed expiring_soon_days column from user_preferences table") 84 | else: 85 | print("user_preferences table does not exist, nothing to downgrade") -------------------------------------------------------------------------------- /backend/migrations/006_add_currency_symbol_column.py: -------------------------------------------------------------------------------- 1 | # Migration: Add currency_symbol column to user_preferences 2 | import psycopg2 3 | 4 | def upgrade(cur): 5 | # Check if column already exists 6 | cur.execute(""" 7 | SELECT column_name FROM information_schema.columns 8 | WHERE table_name='user_preferences' AND column_name='currency_symbol' 9 | """) 10 | if not cur.fetchone(): 11 | cur.execute(""" 12 | ALTER TABLE user_preferences ADD COLUMN currency_symbol VARCHAR(8) DEFAULT '$'; 13 | """) 14 | print("Added currency_symbol column to user_preferences table") 15 | 16 | def downgrade(cur): 17 | cur.execute(""" 18 | ALTER TABLE user_preferences DROP COLUMN IF EXISTS currency_symbol; 19 | """) 20 | print("Removed currency_symbol column from user_preferences table") -------------------------------------------------------------------------------- /backend/migrations/007_add_notification_preferences.sql: -------------------------------------------------------------------------------- 1 | -- Add notification preferences columns if they don't exist 2 | DO $$ 3 | BEGIN 4 | -- Check if notification_frequency column exists 5 | IF NOT EXISTS ( 6 | SELECT 1 7 | FROM information_schema.columns 8 | WHERE table_name = 'user_preferences' 9 | AND column_name = 'notification_frequency' 10 | ) THEN 11 | ALTER TABLE user_preferences 12 | ADD COLUMN notification_frequency VARCHAR(10) NOT NULL DEFAULT 'daily'; 13 | END IF; 14 | 15 | -- Check if notification_time column exists 16 | IF NOT EXISTS ( 17 | SELECT 1 18 | FROM information_schema.columns 19 | WHERE table_name = 'user_preferences' 20 | AND column_name = 'notification_time' 21 | ) THEN 22 | ALTER TABLE user_preferences 23 | ADD COLUMN notification_time VARCHAR(5) NOT NULL DEFAULT '09:00'; 24 | END IF; 25 | END $$; -------------------------------------------------------------------------------- /backend/migrations/008_add_tags.sql: -------------------------------------------------------------------------------- 1 | -- Add tags table and warranty_tags junction table 2 | DO $$ 3 | BEGIN 4 | -- Create tags table if it doesn't exist 5 | IF NOT EXISTS ( 6 | SELECT 1 FROM information_schema.tables 7 | WHERE table_name = 'tags' 8 | ) THEN 9 | CREATE TABLE tags ( 10 | id SERIAL PRIMARY KEY, 11 | name VARCHAR(50) NOT NULL, 12 | color VARCHAR(7) NOT NULL DEFAULT '#808080', 13 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 14 | UNIQUE(name) 15 | ); 16 | 17 | -- Create index on tag name 18 | CREATE INDEX idx_tag_name ON tags(name); 19 | END IF; 20 | 21 | -- Create warranty_tags junction table if it doesn't exist 22 | IF NOT EXISTS ( 23 | SELECT 1 FROM information_schema.tables 24 | WHERE table_name = 'warranty_tags' 25 | ) THEN 26 | CREATE TABLE warranty_tags ( 27 | warranty_id INTEGER NOT NULL, 28 | tag_id INTEGER NOT NULL, 29 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 30 | PRIMARY KEY (warranty_id, tag_id), 31 | FOREIGN KEY (warranty_id) REFERENCES warranties(id) ON DELETE CASCADE, 32 | FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE 33 | ); 34 | 35 | -- Create indexes for better query performance 36 | CREATE INDEX idx_warranty_tags_warranty_id ON warranty_tags(warranty_id); 37 | CREATE INDEX idx_warranty_tags_tag_id ON warranty_tags(tag_id); 38 | END IF; 39 | END $$; -------------------------------------------------------------------------------- /backend/migrations/009_add_admin_flag_to_tags.sql: -------------------------------------------------------------------------------- 1 | -- Add is_admin_tag column to tags table 2 | ALTER TABLE tags 3 | ADD COLUMN IF NOT EXISTS is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE; 4 | 5 | -- Optional: Add an index for faster lookups based on admin status 6 | CREATE INDEX IF NOT EXISTS idx_tags_is_admin_tag ON tags (is_admin_tag); -------------------------------------------------------------------------------- /backend/migrations/010_configure_admin_roles.sql: -------------------------------------------------------------------------------- 1 | -- Migration: Configure PostgreSQL Admin Role 2 | 3 | -- Create a new database role for admin operations 4 | DO $$ 5 | BEGIN 6 | -- Check if the db_admin_user exists, if not create it 7 | IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%(db_admin_user)s') THEN 8 | CREATE ROLE %(db_admin_user)s WITH LOGIN PASSWORD '%(db_admin_password)s'; 9 | END IF; 10 | END 11 | $$; 12 | 13 | -- Grant privileges to the admin role 14 | GRANT ALL PRIVILEGES ON DATABASE %(db_name)s TO %(db_admin_user)s; 15 | GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO %(db_admin_user)s; 16 | GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO %(db_admin_user)s; 17 | GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO %(db_admin_user)s; 18 | 19 | -- Grant specific role management permissions 20 | ALTER ROLE %(db_admin_user)s WITH CREATEROLE; 21 | 22 | -- Ensure the db_user can still access all application tables 23 | GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO %(db_user)s; 24 | GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO %(db_user)s; 25 | GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO %(db_user)s; 26 | 27 | -- Make db_admin_user the owner of all existing users 28 | -- Note: This would require superuser privileges to execute 29 | -- ALTER ROLE %(db_user)s OWNER TO %(db_admin_user)s; 30 | -------------------------------------------------------------------------------- /backend/migrations/011_ensure_admin_permissions.sql: -------------------------------------------------------------------------------- 1 | -- Migration: Ensure Admin Permissions 2 | 3 | -- Grant superuser privileges to db_user 4 | ALTER ROLE %(db_user)s WITH SUPERUSER; 5 | 6 | -- Ensure all tables are accessible 7 | GRANT ALL PRIVILEGES ON DATABASE %(db_name)s TO %(db_user)s; 8 | GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO %(db_user)s; 9 | GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO %(db_user)s; 10 | GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO %(db_user)s; 11 | 12 | -- Ensure role can create and manage roles 13 | ALTER ROLE %(db_user)s WITH CREATEROLE; 14 | 15 | -- Create a function to ensure the db_user is the owner of all database objects 16 | DO $$ 17 | BEGIN 18 | -- Make db_user the owner of all tables 19 | EXECUTE ( 20 | SELECT 'ALTER TABLE ' || quote_ident(tablename) || ' OWNER TO %(db_user)s;' 21 | FROM pg_tables 22 | WHERE schemaname = 'public' 23 | ); 24 | 25 | -- Make db_user the owner of all sequences 26 | EXECUTE ( 27 | SELECT 'ALTER SEQUENCE ' || quote_ident(sequencename) || ' OWNER TO %(db_user)s;' 28 | FROM pg_sequences 29 | WHERE schemaname = 'public' 30 | ); 31 | 32 | -- Make db_user the owner of all functions 33 | EXECUTE ( 34 | SELECT 'ALTER FUNCTION ' || quote_ident(proname) || '(' || 35 | pg_get_function_arguments(p.oid) || ') OWNER TO %(db_user)s;' 36 | FROM pg_proc p 37 | JOIN pg_namespace n ON p.pronamespace = n.oid 38 | WHERE n.nspname = 'public' 39 | ); 40 | EXCEPTION WHEN OTHERS THEN 41 | -- Log error but continue 42 | RAISE NOTICE 'Error setting ownership: %%', SQLERRM; 43 | END $$; 44 | -------------------------------------------------------------------------------- /backend/migrations/012_add_timezone_column.sql: -------------------------------------------------------------------------------- 1 | -- Add timezone column to user_preferences table if it doesn't exist 2 | DO $$ 3 | BEGIN 4 | IF NOT EXISTS ( 5 | SELECT 1 6 | FROM information_schema.columns 7 | WHERE table_name = 'user_preferences' 8 | AND column_name = 'timezone' 9 | ) THEN 10 | ALTER TABLE user_preferences 11 | ADD COLUMN timezone VARCHAR(50) NOT NULL DEFAULT 'UTC'; 12 | 13 | RAISE NOTICE 'Added timezone column to user_preferences table'; 14 | ELSE 15 | RAISE NOTICE 'timezone column already exists in user_preferences table'; 16 | END IF; 17 | END $$; -------------------------------------------------------------------------------- /backend/migrations/013_add_lifetime_warranty.sql: -------------------------------------------------------------------------------- 1 | -- backend/migrations/011_add_lifetime_warranty.sql 2 | 3 | -- Add is_lifetime column to warranties table if it doesn't exist 4 | ALTER TABLE warranties ADD COLUMN IF NOT EXISTS is_lifetime BOOLEAN NOT NULL DEFAULT FALSE; 5 | 6 | -- Make warranty_years nullable if it's not already 7 | ALTER TABLE warranties ALTER COLUMN warranty_years DROP NOT NULL; 8 | 9 | -- Make expiration_date nullable if it's not already 10 | -- Note: expiration_date might already be nullable depending on previous migrations 11 | -- We can ensure it is nullable like this: 12 | ALTER TABLE warranties ALTER COLUMN expiration_date DROP NOT NULL; 13 | 14 | -- Add an index for the new column 15 | CREATE INDEX IF NOT EXISTS idx_is_lifetime ON warranties(is_lifetime); -------------------------------------------------------------------------------- /backend/migrations/014_add_updated_at_to_warranties.sql: -------------------------------------------------------------------------------- 1 | -- backend/migrations/012_add_updated_at_to_warranties.sql 2 | 3 | -- Add the updated_at column if it doesn't exist 4 | ALTER TABLE warranties 5 | ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; 6 | 7 | -- Create or replace a function to automatically update the timestamp on row update 8 | CREATE OR REPLACE FUNCTION update_modified_column() 9 | RETURNS TRIGGER AS $$ 10 | BEGIN 11 | -- Check if NEW record is distinct from OLD record to avoid unnecessary updates 12 | IF NEW IS DISTINCT FROM OLD THEN 13 | NEW.updated_at = NOW(); 14 | END IF; 15 | RETURN NEW; 16 | END; 17 | $$ language 'plpgsql'; 18 | 19 | -- Drop the trigger if it already exists to avoid errors on re-run 20 | DROP TRIGGER IF EXISTS update_warranties_updated_at ON warranties; 21 | 22 | -- Create the trigger to call the function before any UPDATE on the warranties table 23 | CREATE TRIGGER update_warranties_updated_at 24 | BEFORE UPDATE ON warranties 25 | FOR EACH ROW 26 | EXECUTE FUNCTION update_modified_column(); -------------------------------------------------------------------------------- /backend/migrations/015_allow_fractional_warranty_years.sql: -------------------------------------------------------------------------------- 1 | -- Migration: Change warranty_years to NUMERIC(5,2) to allow fractional years 2 | DO $$ 3 | BEGIN 4 | IF EXISTS ( 5 | SELECT 1 FROM information_schema.columns 6 | WHERE table_name = 'warranties' AND column_name = 'warranty_years' AND data_type = 'integer' 7 | ) THEN 8 | ALTER TABLE warranties ALTER COLUMN warranty_years TYPE NUMERIC(5,2) USING warranty_years::NUMERIC(5,2); 9 | END IF; 10 | END $$; 11 | -------------------------------------------------------------------------------- /backend/migrations/016_add_updated_at_to_tags.sql: -------------------------------------------------------------------------------- 1 | -- 016_add_updated_at_to_tags.sql 2 | -- Adds an updated_at column to the tags table for tracking updates 3 | 4 | ALTER TABLE tags 5 | ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW(); 6 | -------------------------------------------------------------------------------- /backend/migrations/017_add_notes_to_warranties.sql: -------------------------------------------------------------------------------- 1 | -- Migration: Add notes column to warranties table if it does not exist 2 | ALTER TABLE warranties ADD COLUMN IF NOT EXISTS notes TEXT; -------------------------------------------------------------------------------- /backend/migrations/018_add_vendor_to_warranties.sql: -------------------------------------------------------------------------------- 1 | -- Migration to add vendor field to warranties table 2 | ALTER TABLE warranties ADD COLUMN IF NOT EXISTS 3 | vendor VARCHAR(255) NULL; -------------------------------------------------------------------------------- /backend/migrations/019_add_date_format_column.sql: -------------------------------------------------------------------------------- 1 | -- Add date_format column to user_preferences table if it doesn't exist 2 | DO $$ 3 | BEGIN 4 | IF NOT EXISTS ( 5 | SELECT 1 6 | FROM information_schema.columns 7 | WHERE table_name = 'user_preferences' 8 | AND column_name = 'date_format' 9 | ) THEN 10 | -- Add the column with a default value of 'MDY' 11 | ALTER TABLE user_preferences 12 | ADD COLUMN date_format VARCHAR(10) NOT NULL DEFAULT 'MDY'; 13 | 14 | RAISE NOTICE 'Added date_format column to user_preferences table with default MDY'; 15 | ELSE 16 | RAISE NOTICE 'date_format column already exists in user_preferences table'; 17 | END IF; 18 | END $$; -------------------------------------------------------------------------------- /backend/migrations/020_add_user_id_to_tags.sql: -------------------------------------------------------------------------------- 1 | -- Add user_id column to tags table and update constraints 2 | DO $$ 3 | BEGIN 4 | -- Add user_id column if it doesn't exist 5 | IF NOT EXISTS ( 6 | SELECT 1 FROM information_schema.columns 7 | WHERE table_name = 'tags' AND column_name = 'user_id' 8 | ) THEN 9 | -- First, add the column as nullable 10 | ALTER TABLE tags ADD COLUMN user_id INTEGER; 11 | 12 | -- Update existing tags to have user_id = 1 (assuming this is the admin user) 13 | UPDATE tags SET user_id = 1 WHERE user_id IS NULL; 14 | 15 | -- Make the column NOT NULL 16 | ALTER TABLE tags ALTER COLUMN user_id SET NOT NULL; 17 | 18 | -- Add foreign key constraint 19 | ALTER TABLE tags ADD CONSTRAINT fk_tags_user_id 20 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; 21 | 22 | -- Drop the old unique constraint on name 23 | ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_name_key; 24 | 25 | -- Add new unique constraint on name and user_id 26 | ALTER TABLE tags ADD CONSTRAINT tags_name_user_id_key 27 | UNIQUE (name, user_id); 28 | 29 | -- Create index for faster lookups 30 | CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags (user_id); 31 | END IF; 32 | END $$; -------------------------------------------------------------------------------- /backend/migrations/021_change_warranty_duration_to_components.sql: -------------------------------------------------------------------------------- 1 | -- Migration: Change warranty_years to separate year, month, and day components (Revised Logic) 2 | DO $$ 3 | DECLARE 4 | warranty_years_exists BOOLEAN; 5 | BEGIN 6 | -- Check if the old warranty_years column exists 7 | SELECT EXISTS ( 8 | SELECT 1 FROM information_schema.columns 9 | WHERE table_name = 'warranties' AND column_name = 'warranty_years' 10 | ) INTO warranty_years_exists; 11 | 12 | -- Add new columns for duration components if they don't exist 13 | ALTER TABLE warranties 14 | ADD COLUMN IF NOT EXISTS warranty_duration_years INTEGER, 15 | ADD COLUMN IF NOT EXISTS warranty_duration_months INTEGER, 16 | ADD COLUMN IF NOT EXISTS warranty_duration_days INTEGER; 17 | 18 | -- Populate new columns ONLY IF warranty_years exists 19 | IF warranty_years_exists THEN 20 | RAISE NOTICE 'warranty_years column exists. Populating new duration columns...'; 21 | 22 | -- Populate from existing warranty_years data where it's not NULL 23 | -- This assumes warranty_years was NUMERIC(5,2) 24 | UPDATE warranties 25 | SET 26 | warranty_duration_years = FLOOR(warranty_years), 27 | warranty_duration_months = ROUND((warranty_years - FLOOR(warranty_years)) * 12), 28 | warranty_duration_days = 0 -- Initialize days to 0 for existing data 29 | WHERE warranty_years IS NOT NULL; -- Only for non-lifetime warranties if they used warranty_years 30 | 31 | -- Set default values for new columns for rows where warranty_years might have been NULL 32 | -- (e.g., for lifetime warranties) 33 | UPDATE warranties 34 | SET 35 | warranty_duration_years = COALESCE(warranty_duration_years, 0), 36 | warranty_duration_months = COALESCE(warranty_duration_months, 0), 37 | warranty_duration_days = COALESCE(warranty_duration_days, 0) 38 | WHERE is_lifetime = TRUE OR warranty_years IS NULL; 39 | 40 | ELSE 41 | RAISE NOTICE 'warranty_years column does not exist. Skipping population based on it.'; 42 | END IF; 43 | 44 | -- Ensure any remaining NULLs in new columns are set to 0 (covers partial runs or cases where warranty_years was already dropped) 45 | RAISE NOTICE 'Ensuring new duration columns have default values...'; 46 | UPDATE warranties 47 | SET 48 | warranty_duration_years = COALESCE(warranty_duration_years, 0), 49 | warranty_duration_months = COALESCE(warranty_duration_months, 0), 50 | warranty_duration_days = COALESCE(warranty_duration_days, 0) 51 | WHERE warranty_duration_years IS NULL OR warranty_duration_months IS NULL OR warranty_duration_days IS NULL; 52 | 53 | -- Add NOT NULL constraints and DEFAULT values to the new columns 54 | -- These are safe to run even if they already exist (Postgres handles it) 55 | RAISE NOTICE 'Applying constraints and defaults to new duration columns...'; 56 | ALTER TABLE warranties 57 | ALTER COLUMN warranty_duration_years SET DEFAULT 0, 58 | ALTER COLUMN warranty_duration_years SET NOT NULL, 59 | ALTER COLUMN warranty_duration_months SET DEFAULT 0, 60 | ALTER COLUMN warranty_duration_months SET NOT NULL, 61 | ALTER COLUMN warranty_duration_days SET DEFAULT 0, 62 | ALTER COLUMN warranty_duration_days SET NOT NULL; 63 | 64 | -- Add check constraints to ensure non-negative values 65 | -- Use IF NOT EXISTS for constraints to make it idempotent 66 | IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_warranty_duration_years' AND conrelid = 'warranties'::regclass) THEN 67 | ALTER TABLE warranties ADD CONSTRAINT chk_warranty_duration_years CHECK (warranty_duration_years >= 0); 68 | END IF; 69 | IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_warranty_duration_months' AND conrelid = 'warranties'::regclass) THEN 70 | ALTER TABLE warranties ADD CONSTRAINT chk_warranty_duration_months CHECK (warranty_duration_months >= 0 AND warranty_duration_months < 12); -- Months should be less than 12 71 | END IF; 72 | IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_warranty_duration_days' AND conrelid = 'warranties'::regclass) THEN 73 | ALTER TABLE warranties ADD CONSTRAINT chk_warranty_duration_days CHECK (warranty_duration_days >= 0 AND warranty_duration_days < 366); -- Days reasonable upper limit 74 | END IF; 75 | 76 | -- Drop the old warranty_years column ONLY IF it existed at the start 77 | IF warranty_years_exists THEN 78 | RAISE NOTICE 'Dropping old warranty_years column...'; 79 | ALTER TABLE warranties DROP COLUMN warranty_years; 80 | END IF; 81 | 82 | RAISE NOTICE 'Migration 021 completed successfully.'; 83 | 84 | END $$; 85 | -------------------------------------------------------------------------------- /backend/migrations/022_add_other_document_path.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE warranties 2 | ADD COLUMN other_document_path VARCHAR(255) DEFAULT NULL; 3 | -------------------------------------------------------------------------------- /backend/migrations/023_add_oidc_columns_to_users.sql: -------------------------------------------------------------------------------- 1 | -- Add oidc_sub and oidc_issuer columns to the users table 2 | ALTER TABLE users 3 | ADD COLUMN IF NOT EXISTS oidc_sub VARCHAR(255), 4 | ADD COLUMN IF NOT EXISTS oidc_issuer VARCHAR(255); 5 | 6 | -- Make password_hash nullable for OIDC-only users 7 | -- This assumes the column 'password_hash' exists. 8 | -- If it might not, the original DO $$ block with IF EXISTS for the column is safer. 9 | -- However, given it's a core user attribute, it should exist. 10 | ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; 11 | 12 | -- Add a unique constraint for oidc_sub and oidc_issuer. 13 | -- This will fail if the constraint already exists, which is acceptable as the migration runner 14 | -- should catch the error and skip/log if the migration was already partially applied. 15 | -- A more robust way is to check information_schema, but we're simplifying due to `RAISE` issues. 16 | ALTER TABLE users ADD CONSTRAINT uq_users_oidc_sub_issuer UNIQUE (oidc_sub, oidc_issuer); 17 | 18 | -- Add login_method to user_sessions table 19 | ALTER TABLE user_sessions 20 | ADD COLUMN IF NOT EXISTS login_method VARCHAR(50) DEFAULT 'local'; 21 | 22 | -- Update existing sessions to 'local' if login_method is NULL 23 | UPDATE user_sessions 24 | SET login_method = 'local' 25 | WHERE login_method IS NULL; 26 | 27 | -- Make login_method not nullable after updating existing rows 28 | -- This assumes the column 'login_method' now exists. 29 | ALTER TABLE user_sessions ALTER COLUMN login_method SET NOT NULL; 30 | 31 | -- End of migration 32 | -------------------------------------------------------------------------------- /backend/migrations/024_fix_tags_constraint.sql: -------------------------------------------------------------------------------- 1 | -- Fix tags table constraints to allow per-user tag names 2 | DO $$ 3 | BEGIN 4 | -- Check if user_id column exists, if not add it 5 | IF NOT EXISTS ( 6 | SELECT 1 FROM information_schema.columns 7 | WHERE table_name = 'tags' AND column_name = 'user_id' 8 | ) THEN 9 | -- Add user_id column as nullable first 10 | ALTER TABLE tags ADD COLUMN user_id INTEGER; 11 | 12 | -- Update existing tags to have user_id = 1 (assuming admin user) 13 | UPDATE tags SET user_id = 1 WHERE user_id IS NULL; 14 | 15 | -- Make the column NOT NULL 16 | ALTER TABLE tags ALTER COLUMN user_id SET NOT NULL; 17 | 18 | -- Add foreign key constraint 19 | ALTER TABLE tags ADD CONSTRAINT fk_tags_user_id 20 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; 21 | END IF; 22 | 23 | -- Drop the old unique constraint on name only (if it exists) 24 | IF EXISTS ( 25 | SELECT 1 FROM information_schema.table_constraints 26 | WHERE table_name = 'tags' AND constraint_name = 'tags_name_key' 27 | ) THEN 28 | ALTER TABLE tags DROP CONSTRAINT tags_name_key; 29 | RAISE NOTICE 'Dropped old tags_name_key constraint'; 30 | END IF; 31 | 32 | -- Add new unique constraint on name and user_id (if it doesn't exist) 33 | IF NOT EXISTS ( 34 | SELECT 1 FROM information_schema.table_constraints 35 | WHERE table_name = 'tags' AND constraint_name = 'tags_name_user_id_key' 36 | ) THEN 37 | ALTER TABLE tags ADD CONSTRAINT tags_name_user_id_key 38 | UNIQUE (name, user_id); 39 | RAISE NOTICE 'Added new tags_name_user_id_key constraint'; 40 | END IF; 41 | 42 | -- Create index for faster lookups (if it doesn't exist) 43 | IF NOT EXISTS ( 44 | SELECT 1 FROM pg_indexes 45 | WHERE tablename = 'tags' AND indexname = 'idx_tags_user_id' 46 | ) THEN 47 | CREATE INDEX idx_tags_user_id ON tags (user_id); 48 | RAISE NOTICE 'Created idx_tags_user_id index'; 49 | END IF; 50 | 51 | RAISE NOTICE 'Tags table constraint fix completed successfully'; 52 | END $$; -------------------------------------------------------------------------------- /backend/migrations/apply_migrations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import glob 4 | import psycopg2 5 | import logging 6 | import time 7 | import sys 8 | import importlib.util 9 | 10 | from psycopg2.extensions import AsIs 11 | 12 | # Set up logging 13 | logging.basicConfig( 14 | level=logging.INFO, 15 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 16 | handlers=[logging.StreamHandler()] 17 | ) 18 | logger = logging.getLogger(__name__) 19 | 20 | # PostgreSQL connection details 21 | DB_HOST = os.environ.get('DB_HOST', 'localhost') 22 | DB_PORT = os.environ.get('DB_PORT', '5432') 23 | DB_NAME = os.environ.get('DB_NAME', 'warranty_db') 24 | DB_USER = os.environ.get('DB_USER', 'warranty_user') 25 | DB_PASSWORD = os.environ.get('DB_PASSWORD', 'warranty_password') 26 | DB_ADMIN_USER = os.environ.get('DB_ADMIN_USER', 'warracker_admin') 27 | DB_ADMIN_PASSWORD = os.environ.get('DB_ADMIN_PASSWORD', 'change_this_password_in_production') 28 | 29 | def get_db_connection(max_attempts=5, attempt_delay=5): 30 | """Get a connection to the PostgreSQL database with retry logic""" 31 | for attempt in range(1, max_attempts + 1): 32 | try: 33 | logger.info(f"Attempting to connect to database (attempt {attempt}/{max_attempts})") 34 | 35 | conn = psycopg2.connect( 36 | host=DB_HOST, 37 | port=DB_PORT, 38 | dbname=DB_NAME, 39 | user=DB_USER, 40 | password=DB_PASSWORD, 41 | ) 42 | 43 | # Set autocommit to False for transaction control 44 | conn.autocommit = False 45 | 46 | logger.info("Database connection successful") 47 | return conn 48 | 49 | except Exception as e: 50 | logger.error(f"Database connection error (attempt {attempt}/{max_attempts}): {e}") 51 | 52 | if attempt < max_attempts: 53 | logger.info(f"Retrying in {attempt_delay} seconds...") 54 | time.sleep(attempt_delay) 55 | else: 56 | logger.error("Maximum connection attempts reached. Could not connect to database.") 57 | raise 58 | 59 | def load_python_migration(file_path): 60 | """Load a Python migration module dynamically""" 61 | module_name = os.path.basename(file_path).replace('.py', '') 62 | spec = importlib.util.spec_from_file_location(module_name, file_path) 63 | module = importlib.util.module_from_spec(spec) 64 | spec.loader.exec_module(module) 65 | return module 66 | 67 | def apply_migrations(): 68 | """Apply all SQL and Python migration files in the migrations directory""" 69 | conn = None 70 | try: 71 | conn = get_db_connection() 72 | cur = conn.cursor() 73 | 74 | # Create migrations table if it doesn't exist 75 | cur.execute(""" 76 | CREATE TABLE IF NOT EXISTS migrations ( 77 | id SERIAL PRIMARY KEY, 78 | filename VARCHAR(255) NOT NULL UNIQUE, 79 | applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 80 | ) 81 | """) 82 | conn.commit() 83 | 84 | # Get list of migration files (both SQL and Python) 85 | migration_dir = os.path.dirname(os.path.abspath(__file__)) 86 | sql_files = sorted(glob.glob(os.path.join(migration_dir, '*.sql'))) 87 | py_files = sorted(glob.glob(os.path.join(migration_dir, '*.py'))) 88 | # Filter out this script itself 89 | py_files = [f for f in py_files if os.path.basename(f) != 'apply_migrations.py'] 90 | 91 | # Combine and sort all migration files by name 92 | all_migration_files = sorted(sql_files + py_files) 93 | 94 | if not all_migration_files: 95 | logger.info("No migration files found.") 96 | return 97 | 98 | # Get list of already applied migrations 99 | cur.execute("SELECT filename FROM migrations") 100 | applied_migrations = set([row[0] for row in cur.fetchall()]) 101 | 102 | # Apply each migration file if not already applied 103 | for migration_file in all_migration_files: 104 | filename = os.path.basename(migration_file) 105 | 106 | if filename in applied_migrations: 107 | logger.info(f"Migration {filename} already applied, skipping.") 108 | continue 109 | 110 | logger.info(f"Applying migration: {filename}") 111 | 112 | try: 113 | if migration_file.endswith('.sql'): 114 | # Apply SQL migration 115 | with open(migration_file, 'r') as f: 116 | sql = f.read() 117 | 118 | cur.execute( 119 | sql, 120 | { 121 | "db_name": AsIs(DB_NAME), 122 | "db_user": AsIs(DB_USER), 123 | "db_admin_user": AsIs(DB_ADMIN_USER), 124 | "db_admin_password": AsIs(DB_ADMIN_PASSWORD), 125 | } 126 | ) 127 | elif migration_file.endswith('.py'): 128 | # Apply Python migration 129 | migration_module = load_python_migration(migration_file) 130 | if hasattr(migration_module, 'upgrade'): 131 | migration_module.upgrade(cur) 132 | else: 133 | logger.warning(f"Python migration {filename} does not have an upgrade function, skipping.") 134 | continue 135 | 136 | # Record the migration as applied 137 | cur.execute( 138 | "INSERT INTO migrations (filename) VALUES (%s)", 139 | (filename,) 140 | ) 141 | 142 | conn.commit() 143 | logger.info(f"Migration {filename} applied successfully") 144 | 145 | except Exception as e: 146 | conn.rollback() 147 | logger.error(f"Error applying migration {filename}: {e}") 148 | if migration_file.endswith('.sql'): 149 | logger.error(f"\nQUERY: {sql}\n") 150 | raise 151 | 152 | except Exception as e: 153 | logger.error(f"Migration error: {e}") 154 | if conn: 155 | conn.rollback() 156 | raise 157 | 158 | finally: 159 | if conn: 160 | conn.close() 161 | 162 | if __name__ == "__main__": 163 | try: 164 | apply_migrations() 165 | logger.info("Migrations completed successfully") 166 | except Exception as e: 167 | logger.error(f"Migration process failed: {e}") 168 | sys.exit(1) 169 | -------------------------------------------------------------------------------- /backend/oidc_handler.py: -------------------------------------------------------------------------------- 1 | # backend/oidc_handler.py 2 | import os 3 | import uuid 4 | from datetime import datetime # Ensure timedelta is imported if used, though not in this snippet 5 | from flask import Blueprint, jsonify, redirect, url_for, current_app, request, session 6 | 7 | # Import shared extensions and utilities 8 | from backend.extensions import oauth 9 | from backend.db_handler import get_db_connection, release_db_connection 10 | from backend.auth_utils import generate_token 11 | 12 | import logging 13 | logger = logging.getLogger(__name__) # Or use current_app.logger inside routes 14 | 15 | oidc_bp = Blueprint('oidc', __name__) # url_prefix will be set when registering in app.py 16 | 17 | @oidc_bp.route('/oidc/login') # Original path was /api/oidc/login 18 | def oidc_login_route(): 19 | if not current_app.config.get('OIDC_ENABLED'): 20 | logger.warning("[OIDC_HANDLER] OIDC login attempt while OIDC is disabled.") 21 | return jsonify({'message': 'OIDC (SSO) login is not enabled.'}), 403 22 | 23 | oidc_provider_name = current_app.config.get('OIDC_PROVIDER_NAME') 24 | if not oidc_provider_name: 25 | logger.error("[OIDC_HANDLER] OIDC is enabled but provider name not configured.") 26 | return jsonify({'message': 'OIDC provider not configured correctly.'}), 500 27 | 28 | # Corrected url_for to use blueprint name 29 | redirect_uri = url_for('oidc.oidc_callback_route', _external=True) 30 | 31 | # HTTPS check for production 32 | if os.environ.get('FLASK_ENV') == 'production' and not redirect_uri.startswith('https'): 33 | redirect_uri = redirect_uri.replace('http://', 'https://', 1) 34 | 35 | logger.info(f"[OIDC_HANDLER] /oidc/login redirect_uri: {redirect_uri}") 36 | return oauth.create_client(oidc_provider_name).authorize_redirect(redirect_uri) 37 | 38 | @oidc_bp.route('/oidc/callback') # Original path was /api/oidc/callback 39 | def oidc_callback_route(): 40 | if not current_app.config.get('OIDC_ENABLED'): 41 | logger.warning("[OIDC_HANDLER] OIDC callback received while OIDC is disabled.") 42 | frontend_login_url = os.environ.get('FRONTEND_URL', current_app.config.get('APP_BASE_URL', 'http://localhost:8080')).rstrip('/') + "/login.html" 43 | return redirect(f"{frontend_login_url}?oidc_error=oidc_disabled") 44 | 45 | oidc_provider_name = current_app.config.get('OIDC_PROVIDER_NAME') 46 | if not oidc_provider_name: 47 | logger.error("[OIDC_HANDLER] OIDC provider name not configured for callback.") 48 | frontend_login_url = os.environ.get('FRONTEND_URL', current_app.config.get('APP_BASE_URL', 'http://localhost:8080')).rstrip('/') + "/login.html" 49 | return redirect(f"{frontend_login_url}?oidc_error=oidc_misconfigured") 50 | 51 | client = oauth.create_client(oidc_provider_name) 52 | try: 53 | token_data = client.authorize_access_token() 54 | except Exception as e: 55 | logger.error(f"[OIDC_HANDLER] OIDC callback error authorizing access token: {e}") 56 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 57 | return redirect(f"{frontend_login_url}?oidc_error=token_exchange_failed") 58 | 59 | if not token_data: 60 | logger.error("[OIDC_HANDLER] OIDC callback: Failed to retrieve access token.") 61 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 62 | return redirect(f"{frontend_login_url}?oidc_error=token_missing") 63 | 64 | userinfo = token_data.get('userinfo') 65 | if not userinfo: 66 | try: 67 | userinfo = client.userinfo(token=token_data) 68 | except Exception as e: 69 | logger.error(f"[OIDC_HANDLER] OIDC callback error fetching userinfo: {e}") 70 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 71 | return redirect(f"{frontend_login_url}?oidc_error=userinfo_fetch_failed") 72 | 73 | if not userinfo: 74 | logger.error("[OIDC_HANDLER] OIDC callback: Failed to retrieve userinfo.") 75 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 76 | return redirect(f"{frontend_login_url}?oidc_error=userinfo_missing") 77 | 78 | oidc_subject = userinfo.get('sub') 79 | oidc_issuer = userinfo.get('iss') 80 | 81 | if not oidc_subject: 82 | logger.error("[OIDC_HANDLER] OIDC callback: 'sub' (subject) missing in userinfo.") 83 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 84 | return redirect(f"{frontend_login_url}?oidc_error=subject_missing") 85 | 86 | conn = None 87 | try: 88 | conn = get_db_connection() 89 | with conn.cursor() as cur: 90 | # Check for existing OIDC user 91 | cur.execute("SELECT id, username, email, is_admin FROM users WHERE oidc_sub = %s AND oidc_issuer = %s AND is_active = TRUE", 92 | (oidc_subject, oidc_issuer)) 93 | user_db_data = cur.fetchone() 94 | 95 | user_id = None 96 | is_new_user = False 97 | 98 | if user_db_data: 99 | user_id = user_db_data[0] 100 | logger.info(f"[OIDC_HANDLER] Existing OIDC user found with ID {user_id} for sub {oidc_subject}") 101 | else: 102 | # Check if registration is enabled before creating new users 103 | cur.execute(""" 104 | SELECT EXISTS ( 105 | SELECT FROM information_schema.tables 106 | WHERE table_name = 'site_settings' 107 | ) 108 | """) 109 | table_exists = cur.fetchone()[0] 110 | 111 | registration_enabled = True 112 | if table_exists: 113 | # Get registration_enabled setting 114 | cur.execute("SELECT value FROM site_settings WHERE key = 'registration_enabled'") 115 | result = cur.fetchone() 116 | 117 | if result: 118 | registration_enabled = result[0].lower() == 'true' 119 | 120 | # Check if there are any users (first user can register regardless of setting) 121 | cur.execute('SELECT COUNT(*) FROM users') 122 | user_count = cur.fetchone()[0] 123 | 124 | # If registration is disabled and this is not the first user, deny SSO signup 125 | if not registration_enabled and user_count > 0: 126 | logger.warning(f"[OIDC_HANDLER] New OIDC user registration denied - registrations are disabled. Subject: {oidc_subject}") 127 | frontend_login_url = os.environ.get('FRONTEND_URL', current_app.config.get('APP_BASE_URL', 'http://localhost:8080')).rstrip('/') + "/login.html" 128 | return redirect(f"{frontend_login_url}?oidc_error=registration_disabled") 129 | 130 | # New user provisioning 131 | is_new_user = True 132 | email = userinfo.get('email') 133 | if not email: 134 | logger.error("[OIDC_HANDLER] 'email' missing in userinfo for new OIDC user.") 135 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 136 | return redirect(f"{frontend_login_url}?oidc_error=email_missing_for_new_user") 137 | 138 | # Check for email conflict with local account 139 | cur.execute("SELECT id FROM users WHERE email = %s AND (oidc_sub IS NULL OR oidc_issuer IS NULL)", (email,)) 140 | if cur.fetchone(): 141 | logger.warning(f"[OIDC_HANDLER] Email {email} already exists for a local account. OIDC user cannot be created.") 142 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 143 | return redirect(f"{frontend_login_url}?oidc_error=email_conflict_local_account") 144 | 145 | username = userinfo.get('preferred_username') or userinfo.get('name') or email.split('@')[0] 146 | # Ensure username uniqueness 147 | cur.execute("SELECT id FROM users WHERE username = %s", (username,)) 148 | if cur.fetchone(): 149 | username = f"{username}_{str(uuid.uuid4())[:4]}" # Short random suffix 150 | 151 | first_name = userinfo.get('given_name', '') 152 | last_name = userinfo.get('family_name', '') 153 | 154 | cur.execute('SELECT COUNT(*) FROM users') 155 | user_count = cur.fetchone()[0] 156 | 157 | # Determine admin status: first user OR email matches configured admin email 158 | is_first_user_admin = (user_count == 0) 159 | 160 | admin_email_from_env = current_app.config.get('ADMIN_EMAIL', '').lower() 161 | oidc_user_email_lower = email.lower() if email else '' 162 | 163 | is_email_match_admin = False 164 | if admin_email_from_env and oidc_user_email_lower == admin_email_from_env: 165 | is_email_match_admin = True 166 | logger.info(f"[OIDC_HANDLER] New OIDC user email {oidc_user_email_lower} matches ADMIN_EMAIL {admin_email_from_env}.") 167 | 168 | is_admin = is_first_user_admin or is_email_match_admin 169 | 170 | if is_admin and not is_first_user_admin: 171 | logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} based on email match.") 172 | elif is_first_user_admin: 173 | logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} as they are the first user.") 174 | 175 | 176 | # Insert new OIDC user 177 | cur.execute( 178 | """INSERT INTO users (username, email, first_name, last_name, is_admin, oidc_sub, oidc_issuer, is_active) 179 | VALUES (%s, %s, %s, %s, %s, %s, %s, TRUE) RETURNING id""", 180 | (username, email, first_name, last_name, is_admin, oidc_subject, oidc_issuer) 181 | ) 182 | user_id = cur.fetchone()[0] 183 | logger.info(f"[OIDC_HANDLER] New OIDC user created with ID {user_id} for sub {oidc_subject}") 184 | 185 | if user_id: 186 | app_session_token = generate_token(user_id) # Generate app-specific JWT 187 | 188 | # Update last login timestamp 189 | cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.utcnow(), user_id)) 190 | 191 | # Log OIDC session in user_sessions table 192 | ip_address = request.remote_addr 193 | user_agent = request.headers.get('User-Agent', '') 194 | # Use a different UUID for session_token in DB if needed, or re-use app_session_token if appropriate for your session model 195 | db_session_token = str(uuid.uuid4()) 196 | expires_at = datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA'] 197 | 198 | cur.execute( 199 | 'INSERT INTO user_sessions (user_id, session_token, expires_at, ip_address, user_agent, login_method) VALUES (%s, %s, %s, %s, %s, %s)', 200 | (user_id, db_session_token, expires_at, ip_address, user_agent, 'oidc') 201 | ) 202 | conn.commit() 203 | 204 | frontend_url = os.environ.get('FRONTEND_URL', current_app.config.get('APP_BASE_URL', 'http://localhost:8080')).rstrip('/') 205 | redirect_target = f"{frontend_url}/auth-redirect.html?token={app_session_token}" 206 | if is_new_user: 207 | redirect_target += "&new_user=true" 208 | 209 | logger.info(f"[OIDC_HANDLER] /oidc/callback redirecting to frontend: {redirect_target}") 210 | return redirect(redirect_target) 211 | else: 212 | logger.error("[OIDC_HANDLER] /oidc/callback User ID not established after DB ops.") 213 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 214 | return redirect(f"{frontend_login_url}?oidc_error=user_processing_failed") 215 | 216 | except Exception as e: # Catch more specific psycopg2.Error if preferred 217 | logger.error(f"[OIDC_HANDLER] OIDC callback: Database or general error: {e}", exc_info=True) 218 | if conn: conn.rollback() 219 | frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html" 220 | return redirect(f"{frontend_login_url}?oidc_error=internal_error") 221 | finally: 222 | if conn: release_db_connection(conn) 223 | 224 | @oidc_bp.route('/auth/oidc-status', methods=['GET']) # Path relative to blueprint's url_prefix 225 | def get_oidc_status_route(): 226 | conn = None 227 | try: 228 | conn = get_db_connection() 229 | with conn.cursor() as cur: 230 | cur.execute("SELECT value FROM site_settings WHERE key = 'oidc_enabled'") 231 | result = cur.fetchone() 232 | oidc_is_enabled = False 233 | if result and result[0] is not None: 234 | oidc_is_enabled = str(result[0]).lower() == 'true' 235 | 236 | cur.execute("SELECT value FROM site_settings WHERE key = 'oidc_provider_name'") 237 | provider_name_result = cur.fetchone() 238 | oidc_provider_name = 'SSO Provider' # Default button text 239 | if provider_name_result and provider_name_result[0]: 240 | raw_name = provider_name_result[0] 241 | # Simple capitalization for display 242 | oidc_provider_name = raw_name.capitalize() if raw_name else 'SSO Provider' 243 | 244 | return jsonify({ 245 | "oidc_enabled": oidc_is_enabled, 246 | "oidc_provider_display_name": oidc_provider_name 247 | }), 200 248 | except Exception as e: 249 | logger.error(f"[OIDC_HANDLER] Error fetching OIDC status: {e}") 250 | return jsonify({"oidc_enabled": False, "oidc_provider_display_name": "SSO Provider"}), 200 # Default to false on error 251 | finally: 252 | if conn: 253 | release_db_connection(conn) 254 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.3 2 | gunicorn==23.0.0 3 | psycopg2-binary==2.9.9 4 | Werkzeug==3.0.3 5 | flask-cors==4.0.1 6 | Flask-Login==0.6.3 7 | Flask-Bcrypt==1.0.1 8 | PyJWT==2.8.0 9 | email-validator==2.1.1 10 | APScheduler==3.10.4 11 | python-dateutil==2.9.0 12 | Authlib==1.3.1 13 | requests==2.32.3 14 | gevent==24.2.1 15 | setuptools<81 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | warracker: 5 | build: . 6 | ports: 7 | - "8005:80" 8 | volumes: 9 | - ./uploads:/data/uploads 10 | - ./backend/migrations:/app/migrations 11 | environment: 12 | - DB_HOST=warrackerdb 13 | - DB_NAME=warranty_test 14 | - DB_USER=warranty_user 15 | - DB_PASSWORD=${DB_PASSWORD:-warranty_password} 16 | - DB_ADMIN_USER=warracker_admin 17 | - DB_ADMIN_PASSWORD=${DB_ADMIN_PASSWORD:-change_this_password_in_production} 18 | - SMTP_HOST=${SMTP_HOST:-localhost} 19 | - SMTP_PORT=${SMTP_PORT:-1025} 20 | - SMTP_USERNAME=${SMTP_USERNAME:-notifications@warracker.com} 21 | - SMTP_PASSWORD=${SMTP_PASSWORD:-} 22 | - SECRET_KEY=${SECRET_KEY:-your_very_secret_flask_key_change_me} # For Flask session and JWT 23 | # OIDC SSO Configuration (User needs to set these based on their OIDC provider) 24 | - OIDC_PROVIDER_NAME=${OIDC_PROVIDER_NAME:-oidc} 25 | - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} # e.g., your_oidc_client_id 26 | - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} # e.g., your_oidc_client_secret 27 | - OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-} # e.g., https://your-oidc-provider.com/auth/realms/your-realm 28 | - OIDC_SCOPE=${OIDC_SCOPE:-openid email profile} 29 | # URL settings (Important for redirects and email links) 30 | - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8005} # Public URL of the frontend (matching the port mapping) 31 | - APP_BASE_URL=${APP_BASE_URL:-http://localhost:8005} # Public base URL of the application for links 32 | - PYTHONUNBUFFERED=1 33 | # Memory optimization settings 34 | - WARRACKER_MEMORY_MODE=${WARRACKER_MEMORY_MODE:-optimized} # Options: optimized (default), ultra-light, performance 35 | - MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-16} # Reduced from 32MB default for memory efficiency 36 | - NGINX_MAX_BODY_SIZE_VALUE=${NGINX_MAX_BODY_SIZE_VALUE:-16M} # Match upload limit 37 | depends_on: 38 | warrackerdb: 39 | condition: service_healthy 40 | restart: unless-stopped 41 | command: > 42 | bash -c " 43 | cd /app && ls -la /app/migrations && 44 | python /app/migrations/apply_migrations.py && 45 | python /app/fix_permissions.py && 46 | exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf 47 | " 48 | 49 | warrackerdb: 50 | image: "postgres:15-alpine" 51 | volumes: 52 | - postgres_data:/var/lib/postgresql/data 53 | - ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql 54 | environment: 55 | - POSTGRES_DB=warranty_test 56 | - POSTGRES_USER=warranty_user 57 | - POSTGRES_PASSWORD=${DB_PASSWORD:-warranty_password} 58 | restart: unless-stopped 59 | healthcheck: 60 | test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] 61 | interval: 10s 62 | timeout: 5s 63 | retries: 5 64 | 65 | volumes: 66 | postgres_data: 67 | -------------------------------------------------------------------------------- /frontend/auth-new.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Authentication functionality for Warracker (New UI) 3 | * Handles user login, logout, and authentication state management for the new UI design 4 | */ 5 | 6 | // Authentication state 7 | let currentUser = null; 8 | let authToken = null; 9 | 10 | // Initialize authentication 11 | document.addEventListener('DOMContentLoaded', () => { 12 | console.log('Initializing auth-new.js for new UI'); 13 | 14 | // Initial check of authentication state 15 | checkAuthState(); 16 | 17 | // Set up periodic check of auth state (every 30 seconds) 18 | setInterval(checkAuthState, 30000); 19 | 20 | // Set up logout functionality 21 | setupLogout(); 22 | }); 23 | 24 | /** 25 | * Set up logout functionality 26 | */ 27 | function setupLogout() { 28 | // Look for logout button in the new UI 29 | const logoutButton = document.querySelector('.logout-btn'); 30 | 31 | if (logoutButton) { 32 | console.log('Found logout button in new UI'); 33 | 34 | // Add click event to logout 35 | logoutButton.addEventListener('click', (e) => { 36 | e.preventDefault(); 37 | logout(); 38 | }); 39 | } 40 | } 41 | 42 | /** 43 | * Check if user is authenticated and update UI accordingly 44 | */ 45 | function checkAuthState() { 46 | console.log('Checking auth state for new UI'); 47 | 48 | // Get auth token from localStorage 49 | authToken = localStorage.getItem('auth_token'); 50 | const userInfo = localStorage.getItem('user_info'); 51 | 52 | if (authToken && userInfo) { 53 | try { 54 | currentUser = JSON.parse(userInfo); 55 | updateUIForAuthenticatedUser(); 56 | validateToken(); 57 | } catch (error) { 58 | console.error('Error parsing user info:', error); 59 | clearAuthData(); 60 | updateUIForUnauthenticatedUser(); 61 | } 62 | } else { 63 | updateUIForUnauthenticatedUser(); 64 | } 65 | } 66 | 67 | /** 68 | * Update UI elements for authenticated user in the new UI 69 | */ 70 | function updateUIForAuthenticatedUser() { 71 | console.log('Updating UI for authenticated user'); 72 | 73 | // Find login/register buttons in the new UI based on the screenshot 74 | const loginButton = document.querySelector('a[href="login.html"], a.login'); 75 | const registerButton = document.querySelector('a[href="register.html"], a.register'); 76 | 77 | // Hide login/register buttons if they exist 78 | if (loginButton) { 79 | console.log('Hiding login button'); 80 | loginButton.style.display = 'none'; 81 | } 82 | 83 | if (registerButton) { 84 | console.log('Hiding register button'); 85 | registerButton.style.display = 'none'; 86 | } 87 | 88 | // Show user info if it exists 89 | const userDisplay = document.querySelector('.user-display'); 90 | if (userDisplay && currentUser) { 91 | let displayName = currentUser.username || 'User'; 92 | userDisplay.textContent = displayName; 93 | userDisplay.style.display = 'inline-block'; 94 | } 95 | } 96 | 97 | /** 98 | * Update UI elements for unauthenticated user in the new UI 99 | */ 100 | function updateUIForUnauthenticatedUser() { 101 | console.log('Updating UI for unauthenticated user'); 102 | 103 | // Find login/register buttons in the new UI based on the screenshot 104 | const loginButton = document.querySelector('a[href="login.html"], a.login'); 105 | const registerButton = document.querySelector('a[href="register.html"], a.register'); 106 | 107 | // Show login/register buttons if they exist 108 | if (loginButton) { 109 | console.log('Showing login button'); 110 | loginButton.style.display = 'inline-block'; 111 | } 112 | 113 | if (registerButton) { 114 | console.log('Showing register button'); 115 | registerButton.style.display = 'inline-block'; 116 | } 117 | 118 | // Hide user info if it exists 119 | const userDisplay = document.querySelector('.user-display'); 120 | if (userDisplay) { 121 | userDisplay.style.display = 'none'; 122 | } 123 | } 124 | 125 | /** 126 | * Logout user 127 | */ 128 | async function logout() { 129 | try { 130 | console.log('Logging out user'); 131 | 132 | // Call logout API 133 | const response = await fetch('/api/auth/logout', { 134 | method: 'POST', 135 | headers: { 136 | 'Authorization': `Bearer ${authToken}`, 137 | 'Content-Type': 'application/json' 138 | } 139 | }); 140 | 141 | // Clear auth data regardless of API response 142 | clearAuthData(); 143 | updateUIForUnauthenticatedUser(); 144 | 145 | // Show success message 146 | console.log('Logged out successfully'); 147 | 148 | // Reload page to refresh UI 149 | window.location.reload(); 150 | 151 | } catch (error) { 152 | console.error('Logout error:', error); 153 | 154 | // Still clear auth data even if API call fails 155 | clearAuthData(); 156 | updateUIForUnauthenticatedUser(); 157 | 158 | console.log('Logged out with errors'); 159 | } 160 | } 161 | 162 | /** 163 | * Clear authentication data from localStorage 164 | */ 165 | function clearAuthData() { 166 | localStorage.removeItem('auth_token'); 167 | localStorage.removeItem('user_info'); 168 | authToken = null; 169 | currentUser = null; 170 | } 171 | 172 | /** 173 | * Validate token with the server 174 | */ 175 | async function validateToken() { 176 | if (!authToken) { 177 | clearAuthData(); 178 | updateUIForUnauthenticatedUser(); 179 | return; 180 | } 181 | 182 | try { 183 | // Use the full URL to avoid path issues 184 | const apiUrl = window.location.origin + '/api/auth/validate-token'; 185 | 186 | const response = await fetch(apiUrl, { 187 | method: 'GET', 188 | headers: { 189 | 'Authorization': `Bearer ${authToken}` 190 | } 191 | }); 192 | 193 | if (!response.ok) { 194 | const errorData = await response.json(); 195 | console.error('Token validation failed:', errorData.message); 196 | throw new Error(errorData.message || 'Invalid token'); 197 | } 198 | 199 | // Token is valid, update last active time 200 | const data = await response.json(); 201 | if (data.user) { 202 | currentUser = data.user; 203 | localStorage.setItem('user_info', JSON.stringify(currentUser)); 204 | updateUIForAuthenticatedUser(); 205 | } 206 | 207 | return true; 208 | } catch (error) { 209 | console.error('Token validation error:', error); 210 | clearAuthData(); 211 | updateUIForUnauthenticatedUser(); 212 | 213 | return false; 214 | } 215 | } 216 | 217 | // Export authentication functions for use in other scripts 218 | window.authNew = { 219 | isAuthenticated: () => !!authToken, 220 | getCurrentUser: () => currentUser, 221 | getToken: () => authToken, 222 | checkAuthState, 223 | logout 224 | }; -------------------------------------------------------------------------------- /frontend/auth-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Authenticating... 7 | 8 | 9 | 35 | 36 | 37 |
38 |

Authenticating, please wait...

39 | 40 | 41 |
42 | 43 | 160 | 161 | 162 | 165 | 166 | 188 | 189 | 190 | 191 | -------------------------------------------------------------------------------- /frontend/auth-redirect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Authentication Redirect Script 3 | * 4 | * This script handles redirects based on authentication status: 5 | * - Redirects unauthenticated users from protected pages to login 6 | * - Redirects authenticated users from login/register to index 7 | * 8 | * Usage: 9 | * Include this script at the very beginning of your HTML with: 10 | * 11 | * 12 | * Set data-protected="true" for pages that require authentication (index, status, settings) 13 | * Set data-protected="false" for auth pages (login, register) 14 | */ 15 | 16 | (function() { 17 | // Wait for DOM to be ready enough to execute script 18 | const executeRedirect = () => { 19 | try { 20 | // Get current script element 21 | const currentScript = document.currentScript; 22 | 23 | // Check if isProtected attribute is set 24 | const isProtected = currentScript.getAttribute('data-protected') === 'true'; 25 | 26 | // Check authentication status 27 | const isAuthenticated = !!localStorage.getItem('auth_token'); 28 | const currentPath = window.location.pathname; 29 | 30 | console.log('Auth redirect check - Protected page:', isProtected); 31 | console.log('Auth redirect check - Is authenticated:', isAuthenticated); 32 | console.log('Auth redirect check - Current path:', currentPath); 33 | 34 | // Handle protected pages (index, status, settings) 35 | if (isProtected && !isAuthenticated) { 36 | console.log('Access to protected page without authentication, redirecting to login'); 37 | window.location.href = 'login.html'; 38 | return; 39 | } 40 | 41 | // Handle auth pages (login, register) 42 | if (!isProtected && isAuthenticated) { 43 | console.log('Already authenticated, redirecting from auth page to index'); 44 | window.location.href = 'index.html'; 45 | return; 46 | } 47 | 48 | console.log('No redirect needed'); 49 | } catch (error) { 50 | console.error('Error in auth-redirect.js:', error); 51 | } 52 | }; 53 | 54 | // Execute immediately 55 | executeRedirect(); 56 | })(); -------------------------------------------------------------------------------- /frontend/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Authentication functionality for Warracker 3 | * Handles user login, logout, and authentication state management 4 | */ 5 | 6 | class AuthManager { 7 | constructor() { 8 | this.token = null; 9 | this.currentUser = null; 10 | this.onLogoutCallbacks = []; 11 | 12 | // Initial state load from localStorage 13 | this.token = localStorage.getItem('auth_token'); 14 | const userInfoString = localStorage.getItem('user_info'); 15 | if (userInfoString) { 16 | try { 17 | this.currentUser = JSON.parse(userInfoString); 18 | } catch (e) { 19 | console.error('Auth.js: Corrupt user_info in localStorage. Clearing.'); 20 | this.currentUser = null; 21 | localStorage.removeItem('user_info'); 22 | // Consider clearing token as well if user_info is corrupt 23 | // localStorage.removeItem('auth_token'); 24 | // this.token = null; 25 | } 26 | } 27 | console.log('[Auth.js] Initial state:', { token: this.token ? 'present' : 'null', currentUser: this.currentUser }); 28 | } 29 | 30 | isAuthenticated() { 31 | // User is authenticated if both token and currentUser (with an id) are present 32 | return !!(this.token && this.currentUser && this.currentUser.id); 33 | } 34 | 35 | getCurrentUser() { 36 | return this.currentUser; 37 | } 38 | 39 | getToken() { 40 | // Always return the current state of this.token, which should be synced with localStorage 41 | return this.token; 42 | } 43 | 44 | clearAuthData() { 45 | console.log('[Auth.js] Clearing auth data.'); 46 | localStorage.removeItem('auth_token'); 47 | localStorage.removeItem('user_info'); 48 | this.token = null; 49 | this.currentUser = null; 50 | this.onLogoutCallbacks.forEach(cb => cb()); 51 | } 52 | 53 | onLogout(callback) { 54 | if (typeof callback === 'function') { 55 | this.onLogoutCallbacks.push(callback); 56 | } 57 | } 58 | 59 | async checkAuthState(isInitialLoad = false) { 60 | console.log('[Auth.js] checkAuthState called. Initial load:', isInitialLoad); 61 | this.token = localStorage.getItem('auth_token'); // Re-read token, might have changed (e.g. by auth-redirect.js) 62 | const userInfoString = localStorage.getItem('user_info'); 63 | 64 | this.currentUser = null; // Reset before check 65 | 66 | if (userInfoString) { 67 | try { 68 | this.currentUser = JSON.parse(userInfoString); 69 | } catch (e) { 70 | console.error('Auth.js: Failed to parse user_info from localStorage during checkAuthState. Clearing auth data.', e); 71 | this.clearAuthData(); // Clear potentially corrupt data 72 | this.updateUIBasedOnAuthState(); // Update UI to reflect logged-out state 73 | return; // Exit early 74 | } 75 | } 76 | 77 | if (this.token) { 78 | // If token exists, try to validate it and fetch/confirm user_info 79 | // This is crucial if user_info was missing or to refresh/validate existing user_info 80 | console.log('[Auth.js] Token found. Validating and fetching user info...'); 81 | try { 82 | const response = await fetch('/api/auth/validate-token', { 83 | headers: { 'Authorization': `Bearer ${this.token}` } 84 | }); 85 | 86 | if (response.ok) { 87 | const data = await response.json(); 88 | if (data.valid && data.user && data.user.id) { 89 | this.currentUser = data.user; 90 | localStorage.setItem('user_info', JSON.stringify(this.currentUser)); // Ensure localStorage is up-to-date 91 | console.log('[Auth.js] Token validated, user_info updated/confirmed:', this.currentUser); 92 | } else { 93 | console.warn('[Auth.js] Token validation failed or user data invalid from API. Clearing auth data.'); 94 | this.clearAuthData(); 95 | } 96 | } else { 97 | console.warn(`[Auth.js] Token validation API call failed (status: ${response.status}). Clearing auth data.`); 98 | this.clearAuthData(); 99 | } 100 | } catch (error) { 101 | console.error('[Auth.js] Error validating token / fetching user info:', error); 102 | this.clearAuthData(); 103 | } 104 | } else { 105 | // No token, ensure everything is cleared 106 | if (this.currentUser) { // If there was user_info but no token, clear user_info 107 | console.log('[Auth.js] No token found, but user_info was present. Clearing user_info.'); 108 | this.clearAuthData(); 109 | } 110 | } 111 | 112 | this.updateUIBasedOnAuthState(); 113 | } 114 | 115 | updateUIBasedOnAuthState() { 116 | const isAuthenticated = this.isAuthenticated(); 117 | this._updateDOMForAuthState(isAuthenticated, this.currentUser); 118 | this.dispatchAuthStateEvent(isAuthenticated, this.currentUser); 119 | } 120 | 121 | _updateDOMForAuthState(isAuthenticated, user) { 122 | const authContainer = document.getElementById('authContainer'); 123 | const userMenu = document.getElementById('userMenu'); 124 | const userDisplayName = document.getElementById('userDisplayName'); 125 | const userNameMenu = document.getElementById('userName'); 126 | const userEmailMenu = document.getElementById('userEmail'); 127 | const logoutMenuItem = document.getElementById('logoutMenuItem'); 128 | 129 | // Select all potential login/register buttons more broadly 130 | const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn'); 131 | const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn'); 132 | const genericAuthButtonsContainers = document.querySelectorAll('.auth-buttons'); 133 | 134 | 135 | if (isAuthenticated && user) { 136 | console.log('Auth.js: Updating UI for AUTHENTICATED user:', user); 137 | if (authContainer) { authContainer.style.display = 'none'; authContainer.style.visibility = 'hidden'; } 138 | 139 | if (userMenu) { 140 | userMenu.style.display = 'block'; // Or 'flex' based on CSS 141 | userMenu.style.visibility = 'visible'; 142 | const displayNameText = user.first_name || user.username || 'User'; 143 | const fullNameText = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.username || 'User Name'; 144 | if (userDisplayName) userDisplayName.textContent = displayNameText; 145 | if (userNameMenu) userNameMenu.textContent = fullNameText; 146 | if (userEmailMenu && user.email) userEmailMenu.textContent = user.email; 147 | } 148 | loginButtons.forEach(btn => { btn.style.display = 'none'; btn.style.visibility = 'hidden'; }); 149 | registerButtons.forEach(btn => { btn.style.display = 'none'; btn.style.visibility = 'hidden'; }); 150 | genericAuthButtonsContainers.forEach(container => { 151 | if (container.id !== 'authContainer') { // Avoid double-hiding if authContainer also has .auth-buttons 152 | container.style.display = 'none'; container.style.visibility = 'hidden'; 153 | } 154 | }); 155 | 156 | if (logoutMenuItem) { 157 | logoutMenuItem.style.display = 'flex'; // Assuming it's a flex item 158 | // Ensure logout listener is attached (can be done once in constructor or DOMContentLoaded) 159 | } 160 | } else { 161 | console.log('Auth.js: Updating UI for UNAUTHENTICATED user.'); 162 | if (authContainer) { authContainer.style.display = 'flex'; authContainer.style.visibility = 'visible'; } 163 | 164 | if (userMenu) { userMenu.style.display = 'none'; userMenu.style.visibility = 'hidden'; } 165 | 166 | // Reset user display names if they exist 167 | if (userDisplayName) userDisplayName.textContent = 'User'; 168 | if (userNameMenu) userNameMenu.textContent = 'User Name'; 169 | if (userEmailMenu) userEmailMenu.textContent = 'user@example.com'; 170 | 171 | loginButtons.forEach(btn => { btn.style.display = 'inline-block'; btn.style.visibility = 'visible'; }); 172 | registerButtons.forEach(btn => { btn.style.display = 'inline-block'; btn.style.visibility = 'visible'; }); 173 | genericAuthButtonsContainers.forEach(container => { 174 | if (container.id !== 'authContainer') { 175 | container.style.display = 'flex'; // Or 'block' 176 | container.style.visibility = 'visible'; 177 | } 178 | }); 179 | if (logoutMenuItem) { logoutMenuItem.style.display = 'none'; } 180 | } 181 | } 182 | 183 | dispatchAuthStateEvent(isAuthenticated, user) { 184 | console.log('[Auth.js] Dispatching authStateReady event', { isAuthenticated, user }); 185 | // Ensure this event is dispatched after the current call stack clears 186 | setTimeout(() => { 187 | window.dispatchEvent(new CustomEvent('authStateReady', { 188 | detail: { isAuthenticated, user } 189 | })); 190 | }, 0); 191 | } 192 | 193 | async login(username, password) { 194 | // Assuming showLoading/hideLoading are global or part of another module 195 | if (typeof showLoading === 'function') showLoading(); 196 | try { 197 | const response = await fetch('/api/auth/login', { 198 | method: 'POST', 199 | headers: { 'Content-Type': 'application/json' }, 200 | body: JSON.stringify({ username, password }) 201 | }); 202 | const data = await response.json(); 203 | if (response.ok) { 204 | this.token = data.token; 205 | this.currentUser = data.user; 206 | localStorage.setItem('auth_token', this.token); 207 | localStorage.setItem('user_info', JSON.stringify(this.currentUser)); 208 | this.updateUIBasedOnAuthState(); 209 | return data; // Return data for login.js to handle redirect 210 | } else { 211 | throw new Error(data.message || 'Login failed'); 212 | } 213 | } catch (error) { 214 | this.clearAuthData(); // Ensure state is cleared on login failure 215 | this.updateUIBasedOnAuthState(); 216 | throw error; // Re-throw for login.js to handle 217 | } finally { 218 | if (typeof hideLoading === 'function') hideLoading(); 219 | } 220 | } 221 | 222 | async logout() { 223 | if (typeof showLoading === 'function') showLoading(); 224 | const currentTokenForApiCall = this.token; // Use current token for API call 225 | 226 | this.clearAuthData(); // Clear local state immediately 227 | this.updateUIBasedOnAuthState(); // Update UI to logged-out state 228 | 229 | try { 230 | if (currentTokenForApiCall) { 231 | await fetch('/api/auth/logout', { 232 | method: 'POST', 233 | headers: { 'Authorization': `Bearer ${currentTokenForApiCall}` } 234 | }); 235 | console.log('[Auth.js] Logout API call successful.'); 236 | } 237 | } catch (error) { 238 | console.error('[Auth.js] Logout API call failed, but user is logged out locally.', error); 239 | } finally { 240 | if (typeof hideLoading === 'function') hideLoading(); 241 | // Redirect to login page after all operations 242 | if (window.location.pathname !== '/login.html') { 243 | window.location.href = 'login.html'; 244 | } 245 | } 246 | } 247 | 248 | addAuthHeader(options = {}) { 249 | const token = this.getToken(); 250 | if (!token) return options; 251 | 252 | const headers = options.headers || {}; 253 | return { ...options, headers: { ...headers, 'Authorization': `Bearer ${token}`}}; 254 | } 255 | } 256 | 257 | // Initialize and export 258 | window.auth = new AuthManager(); 259 | 260 | // Initial check on DOMContentLoaded 261 | document.addEventListener('DOMContentLoaded', async () => { 262 | console.log('[Auth.js] DOMContentLoaded - performing initial async auth state check.'); 263 | await window.auth.checkAuthState(true); // Ensure auth state is processed first 264 | 265 | // Setup user menu toggle 266 | const userMenuBtn_original = document.getElementById('userMenuBtn'); 267 | const userMenuDropdown = document.getElementById('userMenuDropdown'); 268 | 269 | if (userMenuBtn_original && userMenuDropdown) { 270 | console.log('[Auth.js] Setting up user menu. Button:', userMenuBtn_original, 'Dropdown:', userMenuDropdown); 271 | 272 | // Robust listener attachment: clone the button to remove any prior listeners 273 | const userMenuBtn = userMenuBtn_original.cloneNode(true); 274 | userMenuBtn_original.parentNode.replaceChild(userMenuBtn, userMenuBtn_original); 275 | 276 | userMenuBtn.addEventListener('click', (e) => { 277 | e.stopPropagation(); // Prevent click from immediately closing due to document listener 278 | console.log('[Auth.js] userMenuBtn clicked - DEBUG INFO:', { 279 | userMenuDropdown: !!userMenuDropdown, 280 | dropdownClassList: userMenuDropdown ? Array.from(userMenuDropdown.classList) : 'not found', 281 | hasActiveClass: userMenuDropdown ? userMenuDropdown.classList.contains('active') : 'dropdown not found', 282 | buttonId: userMenuBtn.id, 283 | dropdownId: userMenuDropdown ? userMenuDropdown.id : 'not found' 284 | }); 285 | 286 | userMenuDropdown.classList.toggle('active'); 287 | 288 | const isNowActive = userMenuDropdown.classList.contains('active'); 289 | console.log('[Auth.js] User menu toggled via userMenuBtn. Active:', isNowActive); 290 | 291 | // Add a temporary debug check to see if it gets closed immediately 292 | setTimeout(() => { 293 | const stillActive = userMenuDropdown.classList.contains('active'); 294 | console.log('[Auth.js] User menu status after 100ms:', stillActive); 295 | if (isNowActive && !stillActive) { 296 | console.warn('[Auth.js] User menu was closed immediately! Possible global click interference.'); 297 | } 298 | }, 100); 299 | }); 300 | console.log('[Auth.js] User menu click listener attached to userMenuBtn.'); 301 | } else { 302 | console.warn('[Auth.js] User menu button (userMenuBtn) or dropdown (userMenuDropdown) not found. Menu interactivity might be affected.'); 303 | } 304 | 305 | // Setup settings gear menu toggle (if elements exist on the current page) 306 | const settingsBtn_original = document.getElementById('settingsBtn'); 307 | const settingsMenu = document.getElementById('settingsMenu'); // The dropdown menu itself 308 | if (settingsBtn_original && settingsMenu) { 309 | const settingsBtn = settingsBtn_original.cloneNode(true); 310 | settingsBtn_original.parentNode.replaceChild(settingsBtn, settingsBtn_original); 311 | 312 | settingsBtn.addEventListener('click', function(e) { 313 | e.stopPropagation(); 314 | settingsMenu.classList.toggle('active'); 315 | console.log('[Auth.js] Settings menu toggled via settingsBtn.'); 316 | }); 317 | console.log('[Auth.js] Settings menu click listener attached to settingsBtn.'); 318 | } 319 | 320 | // Global click listener to close dropdowns - ensure this is added only once 321 | if (!window._authJsGlobalClickListenerAdded) { 322 | document.addEventListener('click', (e) => { 323 | console.log('[Auth.js] Global click detected on:', e.target); 324 | 325 | // Re-fetch elements by ID inside the listener to ensure they are current 326 | const currentDropdown = document.getElementById('userMenuDropdown'); 327 | const currentButton = document.getElementById('userMenuBtn'); // Use the standardized ID 328 | 329 | if (currentDropdown && currentButton && currentDropdown.classList.contains('active')) { 330 | console.log('[Auth.js] User menu is active, checking if click is outside...'); 331 | const isOutsideDropdown = !currentDropdown.contains(e.target); 332 | const isOutsideButton = !currentButton.contains(e.target); 333 | console.log('[Auth.js] Click outside dropdown:', isOutsideDropdown, 'outside button:', isOutsideButton); 334 | 335 | if (isOutsideDropdown && isOutsideButton) { 336 | currentDropdown.classList.remove('active'); 337 | console.log('[Auth.js] User menu closed by global click.'); 338 | } 339 | } 340 | 341 | const currentSettingsMenu = document.getElementById('settingsMenu'); 342 | const currentSettingsBtn = document.getElementById('settingsBtn'); 343 | if (currentSettingsMenu && currentSettingsBtn && currentSettingsMenu.classList.contains('active') && 344 | !currentSettingsMenu.contains(e.target) && !currentSettingsBtn.contains(e.target)) { 345 | currentSettingsMenu.classList.remove('active'); 346 | console.log('[Auth.js] Settings menu closed by global click.'); 347 | } 348 | }); 349 | window._authJsGlobalClickListenerAdded = true; 350 | console.log('[Auth.js] Global click listener for dropdowns added.'); 351 | } 352 | 353 | // Attach logout listener to logout menu item 354 | const logoutMenuItem_original = document.getElementById('logoutMenuItem'); 355 | if (logoutMenuItem_original) { 356 | const logoutMenuItem = logoutMenuItem_original.cloneNode(true); // Ensures fresh listener 357 | logoutMenuItem_original.parentNode.replaceChild(logoutMenuItem, logoutMenuItem_original); 358 | logoutMenuItem.addEventListener('click', () => { 359 | console.log('[Auth.js] Logout menu item clicked.'); 360 | window.auth.logout(); 361 | }); 362 | console.log('[Auth.js] Logout menu item listener attached.'); 363 | } 364 | }); 365 | -------------------------------------------------------------------------------- /frontend/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassanix/Warracker/c968d8660c4bb2302b774fb1e9e413a89f9dee10/frontend/favicon.ico -------------------------------------------------------------------------------- /frontend/file-utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for secure file handling 3 | */ 4 | 5 | /** 6 | * Converts a file path to a secure API endpoint path 7 | * @param {string} path - The file path to convert 8 | * @returns {string} The secure API endpoint path 9 | */ 10 | function secureFilePath(path) { 11 | if (!path) return ''; 12 | 13 | if (path.startsWith('uploads/')) { 14 | return '/api/secure-file/' + path.substring(8); 15 | } 16 | 17 | if (path.startsWith('/uploads/')) { 18 | return '/api/secure-file/' + path.substring(9); 19 | } 20 | 21 | return path; 22 | } 23 | 24 | /** 25 | * Opens a file in a new tab with proper authentication 26 | * @param {string} path - The file path to open 27 | */ 28 | function openSecureFile(path) { 29 | if (!path) return; 30 | 31 | const securePath = secureFilePath(path); 32 | console.log('Opening file:', securePath); 33 | 34 | // Try to get the token from different sources 35 | const token = localStorage.getItem('auth_token'); 36 | 37 | if (!token) { 38 | console.error('No authentication token available'); 39 | alert('You must be logged in to access files'); 40 | return; 41 | } 42 | 43 | // Create a link with target="_blank" and click it programmatically 44 | const link = document.createElement('a'); 45 | link.href = securePath; 46 | link.target = '_blank'; 47 | 48 | // Add a click event listener to inject the token 49 | link.addEventListener('click', function(e) { 50 | // Prevent the default navigation 51 | e.preventDefault(); 52 | 53 | // Make a fetch request with the Authorization header 54 | fetch(securePath, { 55 | method: 'GET', 56 | headers: { 57 | 'Authorization': `Bearer ${token}` 58 | } 59 | }) 60 | .then(response => { 61 | if (!response.ok) { 62 | throw new Error(`HTTP error! Status: ${response.status}`); 63 | } 64 | return response.blob(); 65 | }) 66 | .then(blob => { 67 | // Create a URL for the blob and open it in a new window 68 | const url = URL.createObjectURL(blob); 69 | window.open(url, '_blank'); 70 | }) 71 | .catch(error => { 72 | console.error('Error opening file:', error); 73 | alert('Error opening file. Please try again or check if you are logged in.'); 74 | }); 75 | }); 76 | 77 | // Trigger the click event 78 | document.body.appendChild(link); 79 | link.click(); 80 | document.body.removeChild(link); 81 | } 82 | 83 | /** 84 | * Downloads a file using the secure file endpoint 85 | * @param {string} path - The file path to download 86 | * @param {string} filename - The name to save the file as 87 | */ 88 | function downloadSecureFile(path, filename) { 89 | if (!path) return; 90 | 91 | const securePath = secureFilePath(path); 92 | const token = localStorage.getItem('auth_token'); 93 | 94 | if (!token) { 95 | console.error('No authentication token available'); 96 | alert('You must be logged in to download files'); 97 | return; 98 | } 99 | 100 | fetch(securePath, { 101 | method: 'GET', 102 | headers: { 103 | 'Authorization': `Bearer ${token}` 104 | } 105 | }) 106 | .then(response => { 107 | if (!response.ok) { 108 | throw new Error(`HTTP error! Status: ${response.status}`); 109 | } 110 | return response.blob(); 111 | }) 112 | .then(blob => { 113 | const url = window.URL.createObjectURL(blob); 114 | const a = document.createElement('a'); 115 | a.style.display = 'none'; 116 | a.href = url; 117 | a.download = filename || path.split('/').pop(); 118 | document.body.appendChild(a); 119 | a.click(); 120 | window.URL.revokeObjectURL(url); 121 | }) 122 | .catch(error => { 123 | console.error('Error downloading file:', error); 124 | alert('Error downloading file. Please try again or check if you are logged in.'); 125 | }); 126 | } -------------------------------------------------------------------------------- /frontend/fix-auth-buttons-loader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Script to load the fix-auth-buttons.js script on all pages 3 | * This script should be included in the head of each HTML file 4 | */ 5 | 6 | // Log authentication status for debugging 7 | console.log('fix-auth-buttons-loader.js is running'); 8 | console.log('Auth token exists:', !!localStorage.getItem('auth_token')); 9 | console.log('User info exists:', !!localStorage.getItem('user_info')); 10 | 11 | // Execute immediately to hide buttons as soon as possible 12 | if (localStorage.getItem('auth_token')) { 13 | console.log('User is logged in, attempting to hide login/register buttons immediately'); 14 | 15 | // Hide auth container if it exists 16 | const authContainer = document.getElementById('authContainer'); 17 | if (authContainer) { 18 | console.log('Found authContainer, hiding it'); 19 | authContainer.style.display = 'none'; 20 | } 21 | 22 | // Hide individual buttons if they exist 23 | const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn, .auth-btn.login-btn'); 24 | const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn, .auth-btn.register-btn'); 25 | 26 | console.log('Found login buttons:', loginButtons.length); 27 | console.log('Found register buttons:', registerButtons.length); 28 | 29 | loginButtons.forEach(button => button.style.display = 'none'); 30 | registerButtons.forEach(button => button.style.display = 'none'); 31 | 32 | // Show user menu if it exists 33 | const userMenu = document.getElementById('userMenu'); 34 | if (userMenu) { 35 | console.log('Found userMenu, showing it'); 36 | userMenu.style.display = 'block'; 37 | } 38 | } 39 | 40 | // Create a script element to load the actual fix script 41 | const script = document.createElement('script'); 42 | script.src = 'fix-auth-buttons.js'; 43 | script.async = true; 44 | 45 | // Add the script to the document 46 | document.head.appendChild(script); 47 | 48 | console.log('Added fix-auth-buttons.js script to the page'); -------------------------------------------------------------------------------- /frontend/fix-auth-buttons.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Script to fix the login and register buttons in the new UI 3 | * This script specifically targets the buttons shown in the screenshot 4 | */ 5 | 6 | // Log execution 7 | console.log('fix-auth-buttons.js loaded and executing'); 8 | 9 | // Function to check if user is authenticated 10 | function isAuthenticated() { 11 | const token = localStorage.getItem('auth_token'); 12 | // console.log('Auth token check:', !!token); // Keep console logs minimal here if auth.js is primary 13 | return !!token; 14 | } 15 | 16 | // Function to find elements by text content 17 | function getElementsByText(selector, text) { 18 | const elements = document.querySelectorAll(selector); 19 | return Array.prototype.filter.call(elements, element => element.textContent.trim() === text); 20 | } 21 | 22 | // Function to hide login and register buttons if user is authenticated 23 | function updateAuthButtons() { 24 | // console.log('fix-auth-buttons.js: updateAuthButtons executing...'); // Keep console logs minimal here 25 | if (isAuthenticated()) { 26 | // console.log('fix-auth-buttons.js: User is authenticated, hiding login/register buttons'); 27 | const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn, .auth-btn.login-btn'); 28 | const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn, .auth-btn.register-btn'); 29 | const authContainer = document.getElementById('authContainer'); 30 | const userMenu = document.getElementById('userMenu'); // Ensure this ID is consistent or use userMenuBtn's parent 31 | 32 | loginButtons.forEach(button => { button.style.display = 'none'; button.style.visibility = 'hidden'; }); 33 | registerButtons.forEach(button => { button.style.display = 'none'; button.style.visibility = 'hidden'; }); 34 | if (authContainer) { authContainer.style.display = 'none'; authContainer.style.visibility = 'hidden';} 35 | if (userMenu) { userMenu.style.display = 'block'; userMenu.style.visibility = 'visible'; } 36 | 37 | const userInfo = localStorage.getItem('user_info'); 38 | if (userInfo) { 39 | try { 40 | const user = JSON.parse(userInfo); 41 | const displayName = user.first_name || user.username || 'User'; 42 | const userDisplayName = document.getElementById('userDisplayName'); 43 | if (userDisplayName) userDisplayName.textContent = displayName; 44 | 45 | const userNameMenu = document.getElementById('userName'); 46 | if (userNameMenu) { 47 | userNameMenu.textContent = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.username || 'User Name'; 48 | } 49 | const userEmailMenu = document.getElementById('userEmail'); 50 | if (userEmailMenu && user.email) userEmailMenu.textContent = user.email; 51 | } catch (error) { /* console.error('fix-auth-buttons.js: Error parsing user info:', error); */ } 52 | } 53 | } else { 54 | // console.log('fix-auth-buttons.js: User is not authenticated, showing login/register buttons'); 55 | const loginButtons = document.querySelectorAll('a[href="login.html"], .login-btn, .auth-btn.login-btn'); 56 | const registerButtons = document.querySelectorAll('a[href="register.html"], .register-btn, .auth-btn.register-btn'); 57 | const authContainer = document.getElementById('authContainer'); 58 | const userMenu = document.getElementById('userMenu'); 59 | 60 | loginButtons.forEach(button => { button.style.display = 'inline-block'; button.style.visibility = 'visible'; }); 61 | registerButtons.forEach(button => { button.style.display = 'inline-block'; button.style.visibility = 'visible'; }); 62 | if (authContainer) { authContainer.style.display = 'flex'; authContainer.style.visibility = 'visible'; } 63 | if (userMenu) { userMenu.style.display = 'none'; userMenu.style.visibility = 'hidden'; } 64 | } 65 | } 66 | 67 | // Run immediately 68 | // console.log('Running updateAuthButtons immediately from fix-auth-buttons.js'); 69 | updateAuthButtons(); 70 | 71 | // Update auth buttons when page loads 72 | document.addEventListener('DOMContentLoaded', () => { 73 | // console.log('DOMContentLoaded event triggered in fix-auth-buttons.js, updating auth buttons'); 74 | updateAuthButtons(); 75 | // REMOVE: setupUserMenuDropdown(); 76 | }); -------------------------------------------------------------------------------- /frontend/header-fix.css: -------------------------------------------------------------------------------- 1 | /* Header Fix CSS */ 2 | 3 | /* Reset header styles to match the main site */ 4 | header { 5 | background-color: var(--card-bg) !important; 6 | box-shadow: var(--shadow) !important; 7 | padding: 20px 0 !important; 8 | margin-bottom: 30px !important; 9 | } 10 | 11 | header .container { 12 | display: flex !important; 13 | justify-content: space-between !important; 14 | align-items: center !important; 15 | padding: 0 20px !important; 16 | } 17 | 18 | .app-title { 19 | display: flex !important; 20 | align-items: center !important; 21 | } 22 | 23 | .app-title h1 { 24 | color: var(--primary-color) !important; 25 | font-size: 2.2rem !important; 26 | margin: 0 !important; 27 | } 28 | 29 | .app-title i { 30 | font-size: 2rem !important; 31 | margin-right: 15px !important; 32 | color: var(--primary-color) !important; 33 | } 34 | 35 | /* Group for right-aligned header elements */ 36 | .header-right-group { 37 | display: flex !important; /* Ensure horizontal layout */ 38 | align-items: center !important; /* Align items vertically */ 39 | gap: 10px !important; /* Add space between items */ 40 | } 41 | 42 | /* Navigation Links */ 43 | .nav-links { 44 | display: flex !important; 45 | gap: 20px !important; 46 | margin: 0 20px !important; 47 | } 48 | 49 | .nav-link { 50 | text-decoration: none !important; 51 | color: var(--text-color) !important; 52 | padding: 8px 12px !important; 53 | border-radius: 4px !important; 54 | transition: background-color 0.3s !important; 55 | display: flex !important; 56 | align-items: center !important; 57 | gap: 8px !important; 58 | } 59 | 60 | .nav-link:hover { 61 | background-color: rgba(0, 0, 0, 0.05) !important; 62 | } 63 | 64 | .nav-link.active { 65 | background-color: rgba(0, 0, 0, 0.05) !important; 66 | font-weight: 600 !important; 67 | } 68 | 69 | .nav-link i { 70 | font-size: 1rem !important; 71 | } 72 | 73 | /* User Menu */ 74 | .user-menu { 75 | position: relative !important; 76 | margin-left: 15px !important; 77 | } 78 | 79 | .user-btn { 80 | background: none !important; 81 | border: none !important; 82 | color: var(--text-color) !important; 83 | cursor: pointer !important; 84 | display: flex !important; 85 | align-items: center !important; 86 | font-size: 0.9rem !important; 87 | padding: 5px 10px !important; 88 | border-radius: 20px !important; 89 | transition: background-color 0.3s !important; 90 | } 91 | 92 | .user-btn:hover { 93 | background-color: rgba(0, 0, 0, 0.05) !important; 94 | } 95 | 96 | .dark-mode .user-btn:hover { 97 | background-color: rgba(255, 255, 255, 0.1) !important; 98 | } 99 | 100 | .user-btn i { 101 | margin-right: 5px !important; 102 | } 103 | 104 | .user-menu-dropdown { 105 | position: absolute !important; 106 | top: 100% !important; 107 | right: 0 !important; 108 | background-color: var(--card-bg) !important; 109 | border-radius: 8px !important; 110 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important; 111 | width: 200px !important; 112 | z-index: 1000 !important; 113 | display: none !important; 114 | padding: 10px 0 !important; 115 | margin-top: 5px !important; 116 | } 117 | 118 | .user-menu-dropdown.active { 119 | display: block !important; 120 | } 121 | 122 | .user-info { 123 | padding: 10px 15px !important; 124 | border-bottom: 1px solid var(--border-color) !important; 125 | margin-bottom: 5px !important; 126 | } 127 | 128 | .user-name { 129 | font-weight: bold !important; 130 | margin-bottom: 5px !important; 131 | } 132 | 133 | .user-email { 134 | font-size: 0.8rem !important; 135 | color: var(--text-muted) !important; 136 | word-break: break-all !important; 137 | } 138 | 139 | .user-menu-item { 140 | padding: 8px 15px !important; 141 | cursor: pointer !important; 142 | transition: background-color 0.3s !important; 143 | display: flex !important; 144 | align-items: center !important; 145 | } 146 | 147 | .user-menu-item:hover { 148 | background-color: rgba(0, 0, 0, 0.05) !important; 149 | } 150 | 151 | .user-menu-item i { 152 | margin-right: 10px !important; 153 | width: 16px !important; 154 | text-align: center !important; 155 | } 156 | 157 | /* Auth Buttons */ 158 | .auth-buttons { 159 | display: flex !important; 160 | gap: 10px !important; 161 | margin-left: 15px !important; 162 | } 163 | 164 | .auth-btn { 165 | padding: 5px 15px !important; 166 | border-radius: 20px !important; 167 | font-size: 0.9rem !important; 168 | display: flex !important; 169 | align-items: center !important; 170 | justify-content: center !important; 171 | cursor: pointer !important; 172 | transition: all 0.3s ease !important; 173 | } 174 | 175 | .auth-btn i { 176 | margin-right: 5px !important; 177 | } 178 | 179 | .login-btn { 180 | background-color: transparent !important; 181 | border: 1px solid var(--primary-color) !important; 182 | color: var(--primary-color) !important; 183 | } 184 | 185 | .login-btn:hover { 186 | background-color: rgba(0, 0, 0, 0.05) !important; 187 | } 188 | 189 | .register-btn { 190 | background-color: var(--primary-color) !important; 191 | border: 1px solid var(--primary-color) !important; 192 | color: white !important; 193 | } 194 | 195 | .register-btn:hover { 196 | background-color: var(--primary-dark) !important; 197 | } 198 | 199 | /* Settings Button */ 200 | .settings-container { 201 | position: relative !important; 202 | margin-left: 10px !important; 203 | z-index: 100 !important; 204 | } 205 | 206 | .settings-btn { 207 | background-color: var(--card-bg) !important; 208 | border: 1px solid var(--border-color) !important; 209 | color: var(--primary-color) !important; 210 | font-size: 1.2rem !important; 211 | cursor: pointer !important; 212 | width: 40px !important; 213 | height: 40px !important; 214 | border-radius: 50% !important; 215 | display: flex !important; 216 | align-items: center !important; 217 | justify-content: center !important; 218 | transition: all 0.3s ease !important; 219 | position: relative !important; 220 | z-index: 101 !important; 221 | pointer-events: auto !important; 222 | box-shadow: var(--shadow) !important; 223 | } 224 | 225 | .settings-btn:hover { 226 | transform: rotate(30deg) !important; 227 | background-color: var(--primary-color) !important; 228 | color: var(--white) !important; 229 | } 230 | 231 | .dark-mode .settings-btn:hover { 232 | background-color: var(--primary-color) !important; 233 | color: var(--white) !important; 234 | } 235 | 236 | .settings-menu { 237 | position: absolute !important; 238 | top: calc(100% + 10px) !important; 239 | right: 0 !important; 240 | background-color: var(--card-bg) !important; 241 | border-radius: var(--border-radius) !important; 242 | box-shadow: var(--shadow) !important; 243 | padding: 15px !important; 244 | min-width: 220px !important; 245 | z-index: 102 !important; 246 | display: none !important; 247 | border: 1px solid var(--border-color) !important; 248 | pointer-events: auto !important; 249 | } 250 | 251 | .settings-menu.active { 252 | display: block !important; 253 | animation: slide-in-top 0.3s forwards !important; 254 | } 255 | 256 | @keyframes slide-in-top { 257 | from { 258 | opacity: 0; 259 | transform: translateY(-10px); 260 | } 261 | to { 262 | opacity: 1; 263 | transform: translateY(0); 264 | } 265 | } 266 | 267 | .settings-item { 268 | padding: 10px 0 !important; 269 | display: flex !important; 270 | justify-content: space-between !important; 271 | align-items: center !important; 272 | border-bottom: 1px solid var(--border-color) !important; 273 | } 274 | 275 | .settings-item:last-child { 276 | border-bottom: none !important; 277 | } 278 | 279 | .settings-link { 280 | color: var(--text-color) !important; 281 | text-decoration: none !important; 282 | display: flex !important; 283 | align-items: center !important; 284 | width: 100% !important; 285 | transition: color 0.3s !important; 286 | } 287 | 288 | .settings-link:hover { 289 | color: var(--primary-color) !important; 290 | } 291 | 292 | .settings-link i { 293 | margin-right: 10px !important; 294 | width: 16px !important; 295 | text-align: center !important; 296 | } 297 | 298 | .github-link { 299 | color: var(--text-color) !important; 300 | } 301 | 302 | .github-link:hover { 303 | color: #6e5494 !important; 304 | } -------------------------------------------------------------------------------- /frontend/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassanix/Warracker/c968d8660c4bb2302b774fb1e9e413a89f9dee10/frontend/img/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassanix/Warracker/c968d8660c4bb2302b774fb1e9e413a89f9dee10/frontend/img/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/img/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassanix/Warracker/c968d8660c4bb2302b774fb1e9e413a89f9dee10/frontend/img/favicon-512x512.png -------------------------------------------------------------------------------- /frontend/include-auth-new.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Immediate Authentication State Handler 3 | * 4 | * This script runs as soon as possible to hide login/register buttons if a user is logged in 5 | * It should be included directly in the HTML before any other scripts 6 | */ 7 | 8 | console.log('include-auth-new.js: Running immediate auth check'); 9 | 10 | // Function to update UI based on auth state (extracted for reuse) 11 | function updateAuthUI() { 12 | if (localStorage.getItem('auth_token')) { 13 | console.log('include-auth-new.js: Updating UI for authenticated user'); 14 | // Inject CSS to hide auth buttons and show user menu 15 | const styleId = 'auth-ui-style'; 16 | let style = document.getElementById(styleId); 17 | if (!style) { 18 | style = document.createElement('style'); 19 | style.id = styleId; 20 | document.head.appendChild(style); 21 | } 22 | style.textContent = ` 23 | #authContainer, .auth-buttons, a[href="login.html"], a[href="register.html"], 24 | .login-btn, .register-btn, .auth-btn.login-btn, .auth-btn.register-btn { 25 | display: none !important; 26 | visibility: hidden !important; 27 | } 28 | #userMenu, .user-menu { 29 | display: block !important; 30 | visibility: visible !important; 31 | } 32 | `; 33 | 34 | // Update user info display elements immediately 35 | try { 36 | var userInfoStr = localStorage.getItem('user_info'); 37 | if (userInfoStr) { 38 | var userInfo = JSON.parse(userInfoStr); 39 | var displayName = userInfo.username || 'User'; 40 | var userDisplayName = document.getElementById('userDisplayName'); 41 | if (userDisplayName) userDisplayName.textContent = displayName; 42 | var userName = document.getElementById('userName'); 43 | if (userName) { 44 | userName.textContent = (userInfo.first_name || '') + ' ' + (userInfo.last_name || ''); 45 | if (!userName.textContent.trim()) userName.textContent = userInfo.username || 'User'; 46 | } 47 | var userEmail = document.getElementById('userEmail'); 48 | if (userEmail && userInfo.email) userEmail.textContent = userInfo.email; 49 | } 50 | } catch (e) { 51 | console.error('include-auth-new.js: Error updating user info display:', e); 52 | } 53 | 54 | } else { 55 | console.log('include-auth-new.js: Updating UI for logged-out user'); 56 | // Inject CSS to show auth buttons and hide user menu 57 | const styleId = 'auth-ui-style'; 58 | let style = document.getElementById(styleId); 59 | if (!style) { 60 | style = document.createElement('style'); 61 | style.id = styleId; 62 | document.head.appendChild(style); 63 | } 64 | style.textContent = ` 65 | #authContainer, .auth-buttons { 66 | display: flex !important; /* Use flex for container */ 67 | visibility: visible !important; 68 | } 69 | a[href="login.html"], a[href="register.html"], 70 | .login-btn, .register-btn, .auth-btn.login-btn, .auth-btn.register-btn { 71 | display: inline-block !important; /* Use inline-block for buttons */ 72 | visibility: visible !important; 73 | } 74 | #userMenu, .user-menu { 75 | display: none !important; 76 | visibility: hidden !important; 77 | } 78 | `; 79 | } 80 | } 81 | 82 | // Immediately check auth state and update UI 83 | updateAuthUI(); 84 | 85 | // Listen for changes to localStorage and update UI without reloading 86 | window.addEventListener('storage', function(event) { 87 | if (event.key === 'auth_token' || event.key === 'user_info') { 88 | console.log(`include-auth-new.js: Storage event detected for ${event.key}. Updating UI.`); 89 | updateAuthUI(); // Update UI instead of reloading 90 | // window.location.reload(); // <-- Keep commented out / Remove permanently 91 | } 92 | }); 93 | 94 | /** 95 | * Script to include the new authentication script in existing HTML files 96 | * This script should be included at the end of the body in each HTML file 97 | */ 98 | 99 | // Function to load and execute the new authentication script 100 | function loadAuthNewScript() { 101 | // Create a script element 102 | const script = document.createElement('script'); 103 | script.src = 'auth-new.js'; 104 | script.async = true; 105 | 106 | // Add the script to the document 107 | document.body.appendChild(script); 108 | 109 | console.log('Added auth-new.js script to the page'); 110 | } 111 | 112 | // Function to check if we're using the new UI 113 | function isNewUI() { 114 | // Check for elements that are specific to the new UI 115 | const loginButton = document.querySelector('a[href="login.html"].auth-btn, a.login'); 116 | const registerButton = document.querySelector('a[href="register.html"].auth-btn, a.register'); 117 | 118 | // If we find these elements, we're using the new UI 119 | return !!(loginButton || registerButton); 120 | } 121 | 122 | // Load the new authentication script if we're using the new UI 123 | if (isNewUI()) { 124 | console.log('Detected new UI, loading auth-new.js'); 125 | loadAuthNewScript(); 126 | } else { 127 | console.log('Using old UI, not loading auth-new.js'); 128 | } -------------------------------------------------------------------------------- /frontend/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Warracker", 3 | "short_name": "Warracker", 4 | "description": "Warracker - Warranty Tracker", 5 | "icons": [ 6 | { 7 | "src": "img/favicon-16x16.png", 8 | "sizes": "16x16", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "img/favicon-32x32.png", 13 | "sizes": "32x32", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "img/favicon-512x512.png", 18 | "sizes": "512x512", 19 | "type": "image/png" 20 | } 21 | ], 22 | "start_url": "./index.html", 23 | "display": "standalone", 24 | "orientation": "portrait", 25 | "background_color": "#ffffff", 26 | "theme_color": "#ffffff" 27 | } 28 | -------------------------------------------------------------------------------- /frontend/registration-status.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Registration Status Checker 3 | * 4 | * This script checks if registration is enabled on the site and hides registration buttons/links if disabled. 5 | * Include this script in all pages that have registration links or buttons. 6 | */ 7 | 8 | document.addEventListener('DOMContentLoaded', function() { 9 | // Only check if user is not logged in 10 | if (!localStorage.getItem('auth_token')) { 11 | checkRegistrationStatus(); 12 | } 13 | }); 14 | 15 | /** 16 | * Check if registration is enabled and hide registration elements if disabled 17 | */ 18 | function checkRegistrationStatus() { 19 | fetch('/api/auth/registration-status') 20 | .then(response => response.json()) 21 | .then(data => { 22 | if (!data.enabled) { 23 | // Hide all registration links and buttons 24 | hideRegistrationElements(); 25 | } 26 | }) 27 | .catch(error => { 28 | console.error('Error checking registration status:', error); 29 | }); 30 | } 31 | 32 | /** 33 | * Hide all registration-related elements on the page 34 | */ 35 | function hideRegistrationElements() { 36 | // Hide elements with specific classes 37 | const elements = [ 38 | '.register-btn', // Main navigation register button 39 | 'a[href="register.html"]', // Links to register page 40 | 'a[href="./register.html"]', // Links to register page (relative) 41 | 'a[href="/register.html"]' // Links to register page (root relative) 42 | ]; 43 | 44 | // Apply to all matching elements 45 | elements.forEach(selector => { 46 | document.querySelectorAll(selector).forEach(element => { 47 | element.style.display = 'none'; 48 | }); 49 | }); 50 | 51 | // Special case for auth container in the header 52 | const authContainer = document.getElementById('authContainer'); 53 | if (authContainer) { 54 | // Check if it has multiple children and at least one is hidden 55 | const children = authContainer.children; 56 | let visibleCount = 0; 57 | 58 | for (let i = 0; i < children.length; i++) { 59 | if (children[i].style.display !== 'none') { 60 | visibleCount++; 61 | } 62 | } 63 | 64 | // If there's only one visible child, adjust the container styling 65 | if (visibleCount === 1) { 66 | authContainer.style.justifyContent = 'flex-end'; 67 | } 68 | } 69 | 70 | // Special case for auth links in login/register pages 71 | const authLinks = document.querySelector('.auth-links'); 72 | if (authLinks) { 73 | const links = authLinks.querySelectorAll('a'); 74 | links.forEach(link => { 75 | if (link.textContent === 'Create Account' || 76 | link.href.includes('register.html')) { 77 | link.style.display = 'none'; 78 | } 79 | }); 80 | 81 | // Add a message about registration being disabled if we're on the login page 82 | if (window.location.pathname.includes('login.html')) { 83 | const infoMessage = document.createElement('div'); 84 | infoMessage.className = 'registration-info'; 85 | infoMessage.innerHTML = 'New account registration is currently disabled'; 86 | infoMessage.style.color = 'var(--text-muted)'; 87 | infoMessage.style.fontSize = '0.8em'; 88 | infoMessage.style.marginTop = '10px'; 89 | authLinks.appendChild(infoMessage); 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /frontend/reset-password-request.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Warracker - Reset Password 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 85 | 86 | 87 | 88 |
89 |
90 |
91 | 92 |

Warracker

93 |
94 |
95 |
96 | 97 | 98 |
99 |
100 |

Reset Your Password

101 | 102 |

103 | Enter your email address below and we'll send you a link to reset your password. 104 |

105 | 106 |
107 | 108 |
109 |
110 | 111 | 112 |
113 | 114 | 117 |
118 | 119 | 122 |
123 |
124 | 125 | 240 | 241 | 242 | 245 | 246 | 268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /frontend/reset-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Warracker - Set New Password 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 142 | 143 | 144 | 145 |
146 |
147 |
148 | 149 |

Warracker

150 |
151 |
152 |
153 | 154 | 155 |
156 |
157 |
158 |

Set New Password

159 | 160 |
161 | 162 |
163 |
164 | 165 |
166 | 167 | 170 |
171 |
172 |
173 | Password must be at least 8 characters and include uppercase, lowercase, and numbers. 174 |
175 | 176 |
177 | 178 |
179 | 180 | 183 |
184 |
185 | 186 | 187 | 188 | 191 |
192 | 193 | 196 |
197 | 198 |
199 | 200 |

Invalid or Expired Link

201 |

The password reset link you clicked is invalid or has expired.

202 | Request New Reset Link 203 |
204 |
205 |
206 | 207 | 428 | 429 | 430 | 433 | 434 | 456 | 457 | 458 | 459 | -------------------------------------------------------------------------------- /frontend/styles.css: -------------------------------------------------------------------------------- 1 | /* Tag Styles */ 2 | .tags-container { 3 | margin-bottom: 15px; 4 | } 5 | 6 | .selected-tags { 7 | display: flex; 8 | flex-wrap: wrap; 9 | gap: 8px; 10 | margin-bottom: 10px; 11 | } 12 | 13 | .tag { 14 | display: inline-flex; 15 | align-items: center; 16 | padding: 4px 8px; 17 | border-radius: 16px; 18 | font-size: 14px; 19 | cursor: pointer; 20 | transition: all 0.2s ease; 21 | margin: 2px; 22 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 23 | } 24 | 25 | .tag .remove-tag { 26 | margin-left: 6px; 27 | font-size: 12px; 28 | opacity: 0.7; 29 | } 30 | 31 | .tag:hover .remove-tag { 32 | opacity: 1; 33 | } 34 | 35 | .tags-dropdown { 36 | position: relative; 37 | } 38 | 39 | .tags-list { 40 | position: absolute; 41 | top: 100%; 42 | left: 0; 43 | right: 0; 44 | background: white; 45 | border: 1px solid #ddd; 46 | border-radius: 4px; 47 | max-height: 200px; 48 | overflow-y: auto; 49 | z-index: 1000; 50 | display: none; 51 | } 52 | 53 | .tags-list.show { 54 | display: block; 55 | } 56 | 57 | .tag-option { 58 | padding: 8px 12px; 59 | cursor: pointer; 60 | } 61 | 62 | .tag-option:hover { 63 | background-color: #f5f5f5; 64 | } 65 | 66 | /* Tag Management Modal */ 67 | #tagManagementModal { 68 | z-index: 1050; 69 | } 70 | 71 | .tag-form { 72 | display: flex; 73 | gap: 10px; 74 | margin-bottom: 20px; 75 | } 76 | 77 | .tag-form input[type="color"] { 78 | width: 50px; 79 | padding: 0; 80 | height: 38px; 81 | } 82 | 83 | .existing-tags { 84 | display: flex; 85 | flex-direction: column; 86 | gap: 10px; 87 | } 88 | 89 | .existing-tag { 90 | display: flex; 91 | align-items: center; 92 | justify-content: space-between; 93 | padding: 8px 12px; 94 | background: var(--bg-secondary); 95 | border-radius: 4px; 96 | } 97 | 98 | .existing-tag-info { 99 | display: flex; 100 | align-items: center; 101 | gap: 10px; 102 | } 103 | 104 | .existing-tag-color { 105 | width: 20px; 106 | height: 20px; 107 | border-radius: 50%; 108 | border: 1px solid var(--border-color); 109 | } 110 | 111 | .existing-tag-actions { 112 | display: flex; 113 | gap: 8px; 114 | } 115 | 116 | .edit-tab-content .tags-container, 117 | .tab-content .tags-container { 118 | background-color: var(--bg-secondary); 119 | border-radius: 8px; 120 | padding: 10px; 121 | margin-bottom: 15px; 122 | } 123 | 124 | .edit-tab-content .selected-tags, 125 | .tab-content .selected-tags { 126 | margin-bottom: 10px; 127 | } 128 | 129 | #editTagSearch, #tagSearch { 130 | background-color: var(--bg-main); 131 | color: var(--text-primary); 132 | border: 1px solid var(--border-color); 133 | } 134 | 135 | /* Fix for the tag modal to appear on top of other modals */ 136 | .modal { 137 | display: none; 138 | position: fixed; 139 | top: 0; 140 | left: 0; 141 | width: 100%; 142 | height: 100%; 143 | background-color: rgba(0, 0, 0, 0.5); 144 | z-index: 1000; 145 | overflow: auto; 146 | } 147 | 148 | .modal-content { 149 | background-color: var(--bg-main); 150 | margin: 50px auto; 151 | padding: 0; 152 | border-radius: 8px; 153 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); 154 | width: 90%; 155 | max-width: 600px; 156 | max-height: 85vh; 157 | overflow-y: auto; 158 | } 159 | 160 | .modal-header { 161 | padding: 15px 20px; 162 | border-bottom: 1px solid var(--border-color); 163 | display: flex; 164 | justify-content: space-between; 165 | align-items: center; 166 | background-color: var(--bg-secondary); 167 | border-radius: 8px 8px 0 0; 168 | } 169 | 170 | .modal-header h2 { 171 | margin: 0; 172 | font-size: 1.25rem; 173 | } 174 | 175 | .modal-body { 176 | padding: 20px; 177 | } 178 | 179 | .modal-footer { 180 | padding: 15px 20px; 181 | border-top: 1px solid var(--border-color); 182 | display: flex; 183 | justify-content: flex-end; 184 | gap: 10px; 185 | } 186 | 187 | .modal .close { 188 | font-size: 24px; 189 | font-weight: bold; 190 | cursor: pointer; 191 | color: var(--text-muted); 192 | } 193 | 194 | .modal .close:hover { 195 | color: var(--text-primary); 196 | } 197 | 198 | /* Add tag indicator in warranty cards */ 199 | .warranty-card .tags-row { 200 | display: flex; 201 | flex-wrap: wrap; 202 | gap: 6px; 203 | margin-top: 10px; 204 | padding-top: 10px; 205 | border-top: 1px solid #eee; 206 | } 207 | 208 | /* Modal z-index layering */ 209 | .modal { 210 | z-index: 1000; 211 | } 212 | 213 | #editModal, #deleteModal { 214 | z-index: 1000; 215 | } 216 | 217 | #tagManagementModal { 218 | z-index: 1050; 219 | } -------------------------------------------------------------------------------- /frontend/sw.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = 'warracker-cache-v1'; 2 | const urlsToCache = [ 3 | './', 4 | './index.html', 5 | './style.css', 6 | './script.js', 7 | './manifest.json', 8 | './img/favicon-16x16.png', 9 | './img/favicon-32x32.png', 10 | './img/favicon-512x512.png' 11 | // Add other important assets here, especially icons declared in manifest.json 12 | ]; 13 | 14 | self.addEventListener('install', event => { 15 | event.waitUntil( 16 | caches.open(CACHE_NAME) 17 | .then(cache => { 18 | console.log('Opened cache'); 19 | return cache.addAll(urlsToCache); 20 | }) 21 | ); 22 | }); 23 | 24 | self.addEventListener('fetch', event => { 25 | event.respondWith( 26 | caches.match(event.request) 27 | .then(response => { 28 | // Cache hit - return response 29 | if (response) { 30 | return response; 31 | } 32 | return fetch(event.request); 33 | } 34 | ) 35 | ); 36 | }); 37 | 38 | self.addEventListener('activate', event => { 39 | const cacheWhitelist = [CACHE_NAME]; 40 | event.waitUntil( 41 | caches.keys().then(cacheNames => { 42 | return Promise.all( 43 | cacheNames.map(cacheName => { 44 | if (cacheWhitelist.indexOf(cacheName) === -1) { 45 | return caches.delete(cacheName); 46 | } 47 | }) 48 | ); 49 | }) 50 | ); 51 | }); -------------------------------------------------------------------------------- /frontend/theme-loader.js: -------------------------------------------------------------------------------- 1 | // theme-loader.js 2 | (function() { 3 | try { 4 | // Function to apply the theme directly to the root element 5 | function applyTheme(isDark) { 6 | const theme = isDark ? 'dark' : 'light'; 7 | document.documentElement.setAttribute('data-theme', theme); 8 | // No need for console log in production loader script 9 | // console.log(`Theme applied on load: ${theme}`); 10 | } 11 | 12 | // Default to light theme initially 13 | let isDarkMode = false; 14 | 15 | // Check localStorage for the standardized 'darkMode' key 16 | const savedTheme = localStorage.getItem('darkMode'); 17 | 18 | if (savedTheme !== null) { 19 | // Use the saved theme preference 20 | isDarkMode = savedTheme === 'true'; 21 | } else { 22 | // Fallback to system preference if no theme saved in localStorage 23 | const prefersDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 24 | // console.log(`No saved theme found, falling back to system preference: ${prefersDarkMode ? 'dark' : 'light'}`); 25 | isDarkMode = prefersDarkMode; 26 | // Optionally, save the detected system preference as the initial setting for future loads 27 | // localStorage.setItem('darkMode', isDarkMode); // Consider if this is desired behavior 28 | } 29 | 30 | // Apply the determined theme 31 | applyTheme(isDarkMode); 32 | 33 | } catch (e) { 34 | console.error("Error applying theme from theme-loader.js:", e); 35 | // Fallback to light theme in case of error 36 | document.documentElement.setAttribute('data-theme', 'light'); 37 | } 38 | })(); 39 | -------------------------------------------------------------------------------- /frontend/version-checker.js: -------------------------------------------------------------------------------- 1 | // Version checker for Warracker 2 | document.addEventListener('DOMContentLoaded', () => { 3 | const currentVersion = '0.9.9.9'; // Current version of the application 4 | const updateStatus = document.getElementById('updateStatus'); 5 | const updateLink = document.getElementById('updateLink'); 6 | 7 | // Function to compare versions 8 | function compareVersions(v1, v2) { 9 | const v1Parts = v1.split('.').map(Number); 10 | const v2Parts = v2.split('.').map(Number); 11 | 12 | for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { 13 | const v1Part = v1Parts[i] || 0; 14 | const v2Part = v2Parts[i] || 0; 15 | 16 | if (v1Part > v2Part) return 1; 17 | if (v1Part < v2Part) return -1; 18 | } 19 | return 0; 20 | } 21 | 22 | // Function to check for updates 23 | async function checkForUpdates() { 24 | try { 25 | const response = await fetch('https://api.github.com/repos/sassanix/Warracker/releases/latest'); 26 | if (!response.ok) throw new Error('Failed to fetch release information'); 27 | 28 | const data = await response.json(); 29 | const latestVersion = data.tag_name.replace('v', ''); // Remove 'v' prefix if present 30 | 31 | const comparison = compareVersions(latestVersion, currentVersion); 32 | 33 | if (comparison > 0) { 34 | // New version available 35 | updateStatus.textContent = `New version ${data.tag_name} available!`; 36 | updateStatus.style.color = 'var(--success-color)'; 37 | updateLink.href = data.html_url; 38 | updateLink.style.display = 'inline-block'; 39 | } else { 40 | // Up to date 41 | updateStatus.textContent = 'You are using the latest version'; 42 | updateStatus.style.color = 'var(--success-color)'; 43 | } 44 | } catch (error) { 45 | console.error('Error checking for updates:', error); 46 | updateStatus.textContent = 'Failed to check for updates'; 47 | updateStatus.style.color = 'var(--error-color)'; 48 | } 49 | } 50 | 51 | // Check for updates when the page loads 52 | checkForUpdates(); 53 | }); -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sassanix/Warracker/c968d8660c4bb2302b774fb1e9e413a89f9dee10/images/demo.gif -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name localhost; 5 | # Point root to the frontend directory where static assets reside inside the container 6 | root /var/www/html; 7 | index index.html; 8 | 9 | # Enable detailed error logging 10 | error_log /var/log/nginx/error.log debug; 11 | access_log /var/log/nginx/access.log; 12 | 13 | # Global settings 14 | gzip on; 15 | gzip_types text/plain text/css application/javascript application/json; 16 | client_max_body_size __NGINX_MAX_BODY_SIZE_CONFIG_VALUE__; # Configurable global limit 17 | 18 | # Add CORS headers globally 19 | add_header 'Access-Control-Allow-Origin' '*'; 20 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; 21 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; 22 | 23 | # MIME types - fixed duplicate js entry 24 | types { 25 | text/html html htm shtml; 26 | text/css css; 27 | application/javascript js; 28 | image/png png; 29 | image/jpeg jpg jpeg; 30 | image/gif gif; 31 | image/svg+xml svg svgz; 32 | application/pdf pdf; 33 | image/x-icon ico; 34 | application/json json; 35 | application/manifest+json webmanifest manifest; 36 | } 37 | 38 | # API requests - proxy to backend (fixed upstream host) 39 | # Using ^~ to ensure this prefix match takes precedence over regex extension matches 40 | location ^~ /api/ { 41 | proxy_pass http://127.0.0.1:5000; # Removed trailing slash to pass /api/ prefix 42 | proxy_set_header Host $host; 43 | proxy_set_header X-Real-IP $remote_addr; 44 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 45 | proxy_set_header X-Forwarded-Proto $scheme; 46 | client_max_body_size __NGINX_MAX_BODY_SIZE_CONFIG_VALUE__; # Configurable limit for API location 47 | 48 | # Pass Authorization header to backend 49 | proxy_set_header Authorization $http_authorization; 50 | 51 | # Enhanced proxy settings for file handling 52 | proxy_buffering off; # Disable buffering for file downloads to prevent content-length mismatches 53 | proxy_request_buffering off; # Disable request buffering for uploads 54 | proxy_read_timeout 300s; # Increased timeout for large file transfers 55 | proxy_connect_timeout 30s; 56 | proxy_send_timeout 300s; 57 | 58 | # Prevent proxy from modifying response headers that could cause issues 59 | proxy_set_header Connection ""; 60 | proxy_http_version 1.1; 61 | 62 | # Add debug headers to see what's happening 63 | add_header X-Debug-Message "API request proxied to backend" always; 64 | 65 | # CORS for API 66 | add_header 'Access-Control-Allow-Origin' '*' always; 67 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always; 68 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; 69 | 70 | # CORS preflight 71 | if ($request_method = 'OPTIONS') { 72 | add_header 'Access-Control-Allow-Origin' '*' always; 73 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always; 74 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; 75 | add_header 'Access-Control-Max-Age' 1728000 always; 76 | add_header 'Content-Type' 'text/plain charset=UTF-8' always; 77 | add_header 'Content-Length' 0 always; 78 | return 204; 79 | } 80 | } 81 | 82 | # Uploads - serve files from uploads directory 83 | location /uploads/ { 84 | return 403 "Access forbidden"; 85 | } 86 | 87 | # HTML files - ensure proper content type 88 | location ~ \.html$ { 89 | add_header Content-Type "text/html; charset=utf-8"; 90 | add_header Cache-Control "no-cache, no-store, must-revalidate"; 91 | } 92 | 93 | # Favicon - specific handling 94 | location = /favicon.ico { 95 | log_not_found off; 96 | access_log off; 97 | try_files $uri =404; 98 | expires 7d; 99 | add_header Cache-Control "public, max-age=604800"; 100 | } 101 | 102 | # Static assets (CSS, JS, images) 103 | location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg)$ { 104 | try_files $uri =404; 105 | expires 7d; 106 | add_header Cache-Control "public, max-age=604800"; 107 | } 108 | 109 | # Test uploads path 110 | location = /test-uploads { 111 | add_header Content-Type text/plain; 112 | return 200 "Uploads directory exists: $document_root\n"; 113 | } 114 | 115 | # Specific handling for manifest.json 116 | location = /manifest.json { 117 | alias /var/www/html/manifest.json; # Serve from this specific path 118 | # The global types block should set the correct Content-Type 119 | # (application/manifest+json for 'manifest' extension) 120 | # If not found by alias, it will result in a 404, which is correct. 121 | # No need for try_files here if alias is used and file must exist. 122 | # If you want to be explicit about 404 if alias target doesn't exist: 123 | # if (!-f /var/www/html/manifest.json) { return 404; } 124 | # However, alias itself should handle this. 125 | # Add caching headers if desired, e.g.: 126 | # expires 1d; 127 | # add_header Cache-Control "public, must-revalidate"; 128 | } 129 | 130 | # Default location 131 | location / { 132 | try_files $uri $uri/ /index.html; 133 | } 134 | } 135 | --------------------------------------------------------------------------------