mirror of
https://github.com/sassanix/Warracker.git
synced 2026-01-06 05:29:39 -06:00
Merge pull request #138 from tecosaur/main
Mixed bag: Update compatability + depreceations + bugfixes + OIDC enhancements
This commit is contained in:
@@ -92,6 +92,9 @@ OIDC_ISSUER_URL=
|
||||
# OIDC scope (space-separated list of scopes)
|
||||
OIDC_SCOPE=openid email profile
|
||||
|
||||
# OIDC admin group (optional, requires group scope)
|
||||
OIDC_ADMIN_GROUP=
|
||||
|
||||
### **Development/Debugging Configuration (Optional)**
|
||||
|
||||
# Flask environment (development/production)
|
||||
|
||||
@@ -108,71 +108,6 @@ def ensure_owner_exists():
|
||||
if conn:
|
||||
release_db_connection(conn)
|
||||
|
||||
def init_oidc_client(current_app_instance, db_conn_func, db_release_func):
|
||||
"""Function to initialize OIDC client based on settings"""
|
||||
from .extensions import oauth
|
||||
|
||||
logger.info("[FACTORY OIDC_INIT] Attempting to initialize OIDC client...")
|
||||
conn = None
|
||||
oidc_db_settings = {}
|
||||
try:
|
||||
conn = db_conn_func()
|
||||
if conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT key, value FROM site_settings WHERE key LIKE 'oidc_%%'")
|
||||
for row in cur.fetchall():
|
||||
oidc_db_settings[row[0]] = row[1]
|
||||
logger.info(f"[FACTORY OIDC_INIT] Fetched OIDC settings from DB: {oidc_db_settings}")
|
||||
else:
|
||||
logger.error("[FACTORY OIDC_INIT] Database connection failed, cannot fetch OIDC settings from DB.")
|
||||
except Exception as e:
|
||||
logger.error(f"[FACTORY OIDC_INIT] Error fetching OIDC settings from DB: {e}. Proceeding without DB settings.")
|
||||
finally:
|
||||
if conn:
|
||||
db_release_func(conn)
|
||||
|
||||
# Priority: Environment Variable > Database Setting > Hardcoded Default
|
||||
# Check Environment Variable first for OIDC enabled
|
||||
oidc_enabled_from_env = os.environ.get('OIDC_ENABLED')
|
||||
if oidc_enabled_from_env is not None:
|
||||
# If the environment variable is set (even to 'false'), it takes highest priority
|
||||
is_enabled = oidc_enabled_from_env.lower() == 'true'
|
||||
else:
|
||||
# If no environment variable, fall back to the database setting
|
||||
oidc_enabled_from_db = oidc_db_settings.get('oidc_enabled', 'false') # Default to 'false' if not in DB
|
||||
is_enabled = oidc_enabled_from_db.lower() == 'true'
|
||||
|
||||
current_app_instance.config['OIDC_ENABLED'] = is_enabled
|
||||
logger.info(f"[FACTORY OIDC_INIT] OIDC enabled status: {is_enabled}")
|
||||
|
||||
if is_enabled:
|
||||
# Apply same precedence logic to all OIDC settings
|
||||
provider_name = os.environ.get('OIDC_PROVIDER_NAME', oidc_db_settings.get('oidc_provider_name', 'oidc'))
|
||||
client_id = os.environ.get('OIDC_CLIENT_ID', oidc_db_settings.get('oidc_client_id', ''))
|
||||
client_secret = os.environ.get('OIDC_CLIENT_SECRET', oidc_db_settings.get('oidc_client_secret', ''))
|
||||
issuer_url = os.environ.get('OIDC_ISSUER_URL', oidc_db_settings.get('oidc_issuer_url', ''))
|
||||
scope = os.environ.get('OIDC_SCOPE', oidc_db_settings.get('oidc_scope', 'openid email profile'))
|
||||
|
||||
current_app_instance.config['OIDC_PROVIDER_NAME'] = provider_name
|
||||
|
||||
if client_id and client_secret and issuer_url:
|
||||
logger.info(f"[FACTORY OIDC_INIT] Registering OIDC client '{provider_name}' with Authlib.")
|
||||
oauth.register(
|
||||
name=provider_name,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
server_metadata_url=f"{issuer_url.rstrip('/')}/.well-known/openid-configuration",
|
||||
client_kwargs={'scope': scope},
|
||||
override=True
|
||||
)
|
||||
logger.info(f"[FACTORY OIDC_INIT] OIDC client '{provider_name}' registered successfully.")
|
||||
else:
|
||||
logger.warning("[FACTORY OIDC_INIT] OIDC is enabled, but critical parameters are missing. OIDC login will be unavailable.")
|
||||
current_app_instance.config['OIDC_ENABLED'] = False
|
||||
else:
|
||||
current_app_instance.config['OIDC_PROVIDER_NAME'] = None
|
||||
logger.info("[FACTORY OIDC_INIT] OIDC is disabled.")
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""Create and configure an instance of the Flask application."""
|
||||
# Configure logging FIRST
|
||||
@@ -248,9 +183,11 @@ def create_app(config_name=None):
|
||||
# Initialize OIDC client after extensions and blueprints
|
||||
try:
|
||||
from .db_handler import get_db_connection, release_db_connection
|
||||
from .oidc_handler import init_oidc_client
|
||||
init_oidc_client(app, get_db_connection, release_db_connection)
|
||||
except ImportError:
|
||||
from db_handler import get_db_connection, release_db_connection
|
||||
from oidc_handler import init_oidc_client
|
||||
init_oidc_client(app, get_db_connection, release_db_connection)
|
||||
|
||||
# Ensure an owner exists on startup
|
||||
|
||||
@@ -363,6 +363,7 @@ def get_site_settings():
|
||||
# 'oidc_client_secret': '', # Not returned
|
||||
'oidc_issuer_url': '',
|
||||
'oidc_scope': 'openid email profile',
|
||||
'oidc_admin_group': '',
|
||||
# Apprise default settings
|
||||
'apprise_enabled': 'false',
|
||||
'apprise_urls': '',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# backend/auth_routes.py
|
||||
# Updated: 2025-01-24 - Fixed API endpoints for notifications
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, UTC, timedelta
|
||||
import uuid
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
@@ -117,13 +117,13 @@ def register():
|
||||
token = generate_token(user_id)
|
||||
|
||||
# Update last login
|
||||
cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.utcnow(), user_id))
|
||||
cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.now(UTC), user_id))
|
||||
|
||||
# Store session info
|
||||
ip_address = request.remote_addr
|
||||
user_agent = request.headers.get('User-Agent', '')
|
||||
session_token = str(uuid.uuid4())
|
||||
expires_at = datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA']
|
||||
expires_at = datetime.now(UTC) + current_app.config['JWT_EXPIRATION_DELTA']
|
||||
|
||||
cur.execute(
|
||||
'INSERT INTO user_sessions (user_id, session_token, expires_at, ip_address, user_agent, login_method) VALUES (%s, %s, %s, %s, %s, %s)',
|
||||
@@ -182,13 +182,13 @@ def login():
|
||||
token = generate_token(user_id)
|
||||
|
||||
# Update last login
|
||||
cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.utcnow(), user_id))
|
||||
cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.now(UTC), user_id))
|
||||
|
||||
# Store session info
|
||||
ip_address = request.remote_addr
|
||||
user_agent = request.headers.get('User-Agent', '')
|
||||
session_token = str(uuid.uuid4())
|
||||
expires_at = datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA']
|
||||
expires_at = datetime.now(UTC) + current_app.config['JWT_EXPIRATION_DELTA']
|
||||
|
||||
cur.execute(
|
||||
'INSERT INTO user_sessions (user_id, session_token, expires_at, ip_address, user_agent, login_method) VALUES (%s, %s, %s, %s, %s, %s)',
|
||||
@@ -204,7 +204,8 @@ def login():
|
||||
'id': user_id,
|
||||
'username': user[1],
|
||||
'email': user[2],
|
||||
'is_admin': user[5] # Include is_admin flag
|
||||
'is_admin': user[5],
|
||||
'oidc_managed': False
|
||||
}
|
||||
}), 200
|
||||
except Exception as e:
|
||||
@@ -258,7 +259,8 @@ def validate_token():
|
||||
'username': request.user['username'],
|
||||
'email': request.user['email'],
|
||||
'is_admin': request.user['is_admin'],
|
||||
'is_owner': request.user.get('is_owner', False)
|
||||
'is_owner': request.user.get('is_owner', False),
|
||||
'oidc_managed': request.user['oidc_managed']
|
||||
},
|
||||
'message': 'Token is valid'
|
||||
}), 200
|
||||
@@ -282,12 +284,12 @@ def get_user():
|
||||
# Try to get user with is_owner column, fall back to without it if column doesn't exist
|
||||
try:
|
||||
cur.execute(
|
||||
'SELECT id, username, email, first_name, last_name, is_admin, is_owner FROM users WHERE id = %s',
|
||||
'SELECT id, username, email, first_name, last_name, is_admin, is_owner, oidc_sub FROM users WHERE id = %s',
|
||||
(user_id,)
|
||||
)
|
||||
user_data = cur.fetchone()
|
||||
has_owner_column = True
|
||||
columns = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'is_owner']
|
||||
columns = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'is_owner', 'oidc_managed']
|
||||
except Exception as e:
|
||||
# If the query fails (likely because is_owner column doesn't exist), rollback and try again
|
||||
current_app.logger.warning(f"Failed to query with is_owner column in get_user, falling back: {e}")
|
||||
@@ -298,7 +300,7 @@ def get_user():
|
||||
)
|
||||
user_data = cur.fetchone()
|
||||
has_owner_column = False
|
||||
columns = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin']
|
||||
columns = ['id', 'username', 'email', 'first_name', 'last_name', 'is_admin', 'oidc_managed']
|
||||
# --- END DATABASE QUERY ---
|
||||
|
||||
if not user_data:
|
||||
@@ -311,6 +313,9 @@ def get_user():
|
||||
if not has_owner_column:
|
||||
user_info['is_owner'] = False
|
||||
|
||||
# Turn oidc_sub into a boolean
|
||||
user_info['oidc_managed'] = user_info['oidc_managed'] is not None
|
||||
|
||||
# Return the full user information
|
||||
return jsonify(user_info), 200
|
||||
|
||||
@@ -347,7 +352,7 @@ def request_password_reset():
|
||||
|
||||
# Generate reset token
|
||||
reset_token = str(uuid.uuid4())
|
||||
expires_at = datetime.utcnow() + timedelta(hours=24)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=24)
|
||||
|
||||
# Delete any existing tokens for this user
|
||||
cur.execute('DELETE FROM password_reset_tokens WHERE user_id = %s', (user_id,))
|
||||
@@ -656,7 +661,7 @@ def reset_password():
|
||||
cur.execute('SELECT user_id, expires_at FROM password_reset_tokens WHERE token = %s', (token,))
|
||||
token_info = cur.fetchone()
|
||||
|
||||
if not token_info or token_info[1] < datetime.utcnow():
|
||||
if not token_info or token_info[1] < datetime.now(UTC):
|
||||
return jsonify({'message': 'Invalid or expired token!'}), 400
|
||||
|
||||
user_id = token_info[0]
|
||||
@@ -745,6 +750,8 @@ def send_password_reset_email(recipient_email, reset_link):
|
||||
smtp_port = int(os.environ.get('SMTP_PORT', 1025))
|
||||
smtp_username = os.environ.get('SMTP_USERNAME')
|
||||
smtp_password = os.environ.get('SMTP_PASSWORD')
|
||||
if os.environ.get('SMTP_PASSWORD_FILE'):
|
||||
smtp_password = open(os.environ.get('SMTP_PASSWORD_FILE'), 'r').read().strip()
|
||||
smtp_use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true'
|
||||
smtp_use_ssl = os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true'
|
||||
sender_email = os.environ.get('SMTP_SENDER_EMAIL', 'noreply@warracker.com')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# backend/auth_utils.py
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, UTC, timedelta
|
||||
from flask import current_app, request, jsonify
|
||||
from functools import wraps
|
||||
import re
|
||||
@@ -14,9 +14,9 @@ except ImportError:
|
||||
def generate_token(user_id):
|
||||
"""Generate a JWT token for the user"""
|
||||
payload = {
|
||||
'exp': datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA'],
|
||||
'iat': datetime.utcnow(),
|
||||
'sub': user_id
|
||||
'exp': datetime.now(UTC) + current_app.config['JWT_EXPIRATION_DELTA'],
|
||||
'iat': datetime.now(UTC),
|
||||
'sub': str(user_id)
|
||||
}
|
||||
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
@@ -67,7 +67,7 @@ def token_required(f):
|
||||
with conn.cursor() as cur:
|
||||
# Try to get user with is_owner column, fall back to without it if column doesn't exist
|
||||
try:
|
||||
cur.execute('SELECT id, username, email, is_admin, is_owner FROM users WHERE id = %s AND is_active = TRUE', (user_id,))
|
||||
cur.execute('SELECT id, username, email, is_admin, is_owner, oidc_sub FROM users WHERE id = %s AND is_active = TRUE', (user_id,))
|
||||
user = cur.fetchone()
|
||||
has_owner_column = True
|
||||
except Exception as e:
|
||||
@@ -87,8 +87,13 @@ def token_required(f):
|
||||
'username': user[1],
|
||||
'email': user[2],
|
||||
'is_admin': user[3],
|
||||
'is_owner': user[4] if has_owner_column and len(user) > 4 else False
|
||||
}
|
||||
|
||||
if has_owner_column:
|
||||
request.user.update({
|
||||
'is_owner': user[4],
|
||||
'oidc_managed': user[5] is not None
|
||||
})
|
||||
|
||||
return f(*args, **kwargs)
|
||||
except Exception as e:
|
||||
|
||||
@@ -5,11 +5,28 @@ from datetime import timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_try_create_secret():
|
||||
secret = os.environ.get('SECRET_KEY')
|
||||
if not secret:
|
||||
secret_file = 'secret_key'
|
||||
if os.path.exists(secret_file):
|
||||
secret = open(secret_file, 'r').read().strip()
|
||||
else:
|
||||
try:
|
||||
import base64
|
||||
secret = base64.b64encode(os.urandom(32)).decode('ascii')
|
||||
with open(secret_file, 'w') as f:
|
||||
f.write(secret)
|
||||
logger.info(f"Generated new SECRET_KEY and saved to {secret_file}")
|
||||
except Exception:
|
||||
secret = 'your_default_secret_key_please_change_in_prod'
|
||||
return secret
|
||||
|
||||
class Config:
|
||||
"""Base configuration class."""
|
||||
|
||||
# Flask Core Configuration
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'your_default_secret_key_please_change_in_prod')
|
||||
SECRET_KEY = get_try_create_secret()
|
||||
JWT_EXPIRATION_DELTA = timedelta(hours=int(os.environ.get('JWT_EXPIRATION_HOURS', '24')))
|
||||
|
||||
# Security Warning for Default Secret Key
|
||||
@@ -27,7 +44,7 @@ class Config:
|
||||
DB_ADMIN_PASSWORD = os.environ.get('DB_ADMIN_PASSWORD', 'change_this_password_in_production')
|
||||
|
||||
# File Upload Configuration
|
||||
UPLOAD_FOLDER = '/data/uploads'
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/data/uploads')
|
||||
DEFAULT_MAX_UPLOAD_MB = 32
|
||||
|
||||
@staticmethod
|
||||
@@ -68,6 +85,13 @@ class Config:
|
||||
def init_app(app):
|
||||
"""Initialize configuration-specific settings."""
|
||||
Config._check_secret_key()
|
||||
|
||||
if not os.path.exists(Config.UPLOAD_FOLDER):
|
||||
try:
|
||||
os.makedirs(Config.UPLOAD_FOLDER)
|
||||
logger.info(f"Created upload folder at {Config.UPLOAD_FOLDER}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create upload folder at {Config.UPLOAD_FOLDER}: {e}")
|
||||
|
||||
# Set upload configuration
|
||||
max_upload_mb = Config._get_max_upload_mb()
|
||||
|
||||
@@ -41,8 +41,8 @@ def serve_file(filename):
|
||||
|
||||
# Remove 'uploads/' prefix for send_from_directory
|
||||
file_path = filename[8:] if filename.startswith('uploads/') else filename
|
||||
|
||||
return send_from_directory('/data/uploads', file_path)
|
||||
|
||||
return send_from_directory(current_app.config['UPLOAD_FOLDER'], file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving file {filename}: {e}")
|
||||
return jsonify({"message": "Error accessing file"}), 500
|
||||
@@ -121,21 +121,23 @@ def secure_file_access(filename):
|
||||
if not authorized:
|
||||
logger.warning(f"[SECURE_FILE] Unauthorized file access attempt: '{filename}' (repr: {repr(filename)}) by user {user_id}. DB results count: {len(results) if results else 'None'}")
|
||||
return jsonify({"message": "You are not authorized to access this file"}), 403
|
||||
|
||||
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||
|
||||
logger.info(f"[SECURE_FILE] User {user_id} authorized for file '{filename}'. Attempting to serve from /data/uploads.")
|
||||
logger.info(f"[SECURE_FILE] User {user_id} authorized for file '{filename}'. Attempting to serve from {upload_dir}.")
|
||||
|
||||
# Construct the full file path
|
||||
target_file_path_for_send = os.path.join('/data/uploads', filename)
|
||||
target_file_path_for_send = os.path.join(upload_dir, filename)
|
||||
logger.info(f"[SECURE_FILE] Path for verification: '{target_file_path_for_send}' (repr: {repr(target_file_path_for_send)})")
|
||||
|
||||
# Enhanced file existence and readability checks
|
||||
if not os.path.exists(target_file_path_for_send):
|
||||
logger.error(f"[SECURE_FILE] File '{target_file_path_for_send}' does not exist")
|
||||
try:
|
||||
dir_contents = os.listdir('/data/uploads')
|
||||
logger.info(f"[SECURE_FILE] Contents of /data/uploads: {dir_contents}")
|
||||
dir_contents = os.listdir(upload_dir)
|
||||
logger.info(f"[SECURE_FILE] Contents of {upload_dir}: {dir_contents}")
|
||||
except Exception as list_err:
|
||||
logger.error(f"[SECURE_FILE] Error listing /data/uploads: {list_err}")
|
||||
logger.error(f"[SECURE_FILE] Error listing {upload_dir}: {list_err}")
|
||||
return jsonify({"message": "File not found"}), 404
|
||||
|
||||
if not os.path.isfile(target_file_path_for_send):
|
||||
|
||||
@@ -12,7 +12,7 @@ import time
|
||||
import atexit
|
||||
import smtplib
|
||||
import logging
|
||||
from datetime import datetime, date, timedelta
|
||||
from datetime import datetime, date, UTC, timedelta
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from threading import Lock
|
||||
@@ -299,6 +299,8 @@ def process_email_notifications(all_warranties, eligible_user_ids, is_manual, ge
|
||||
smtp_port = int(os.environ.get('SMTP_PORT', '1025'))
|
||||
smtp_username = os.environ.get('SMTP_USERNAME', 'notifications@warracker.com')
|
||||
smtp_password = os.environ.get('SMTP_PASSWORD', '')
|
||||
if os.environ.get('SMTP_PASSWORD_FILE'):
|
||||
smtp_password = open(os.environ.get('SMTP_PASSWORD_FILE'), 'r').read().strip()
|
||||
smtp_use_tls_env = os.environ.get('SMTP_USE_TLS', 'not_set').lower()
|
||||
|
||||
# For manual triggers, check email preferences
|
||||
@@ -353,7 +355,7 @@ def process_email_notifications(all_warranties, eligible_user_ids, is_manual, ge
|
||||
server.login(smtp_username, smtp_password)
|
||||
|
||||
emails_sent = 0
|
||||
utc_now = datetime.utcnow()
|
||||
utc_now = datetime.now(UTC)
|
||||
timestamp = int(utc_now.timestamp())
|
||||
|
||||
for email, user_data in users_warranties.items():
|
||||
@@ -535,7 +537,7 @@ def send_expiration_notifications(manual_trigger=False, get_db_connection=None,
|
||||
|
||||
if not manual_trigger:
|
||||
with conn.cursor() as cur:
|
||||
utc_now = datetime.utcnow()
|
||||
utc_now = datetime.now(UTC)
|
||||
|
||||
# Check if required columns exist for dynamic query building
|
||||
cur.execute("""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# backend/oidc_handler.py
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime # Ensure timedelta is imported if used, though not in this snippet
|
||||
from datetime import datetime, UTC # Ensure timedelta is imported if used, though not in this snippet
|
||||
from flask import Blueprint, jsonify, redirect, url_for, current_app, request, session
|
||||
|
||||
# Import shared extensions and utilities
|
||||
@@ -21,6 +21,74 @@ logger = logging.getLogger(__name__) # Or use current_app.logger inside routes
|
||||
|
||||
oidc_bp = Blueprint('oidc', __name__) # url_prefix will be set when registering in app.py
|
||||
|
||||
def init_oidc_client(current_app_instance, db_conn_func, db_release_func):
|
||||
"""Function to initialize OIDC client based on settings"""
|
||||
from .extensions import oauth
|
||||
|
||||
logger.info("[FACTORY OIDC_INIT] Attempting to initialize OIDC client...")
|
||||
conn = None
|
||||
oidc_db_settings = {}
|
||||
try:
|
||||
conn = db_conn_func()
|
||||
if conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT key, value FROM site_settings WHERE key LIKE 'oidc_%%'")
|
||||
for row in cur.fetchall():
|
||||
oidc_db_settings[row[0]] = row[1]
|
||||
logger.info(f"[FACTORY OIDC_INIT] Fetched OIDC settings from DB: {oidc_db_settings}")
|
||||
else:
|
||||
logger.error("[FACTORY OIDC_INIT] Database connection failed, cannot fetch OIDC settings from DB.")
|
||||
except Exception as e:
|
||||
logger.error(f"[FACTORY OIDC_INIT] Error fetching OIDC settings from DB: {e}. Proceeding without DB settings.")
|
||||
finally:
|
||||
if conn:
|
||||
db_release_func(conn)
|
||||
|
||||
# Priority: Environment Variable > Database Setting > Hardcoded Default
|
||||
# Check Environment Variable first for OIDC enabled
|
||||
oidc_enabled_from_env = os.environ.get('OIDC_ENABLED')
|
||||
if oidc_enabled_from_env is not None:
|
||||
# If the environment variable is set (even to 'false'), it takes highest priority
|
||||
is_enabled = oidc_enabled_from_env.lower() == 'true'
|
||||
else:
|
||||
# If no environment variable, fall back to the database setting
|
||||
oidc_enabled_from_db = oidc_db_settings.get('oidc_enabled', 'false') # Default to 'false' if not in DB
|
||||
is_enabled = oidc_enabled_from_db.lower() == 'true'
|
||||
|
||||
current_app_instance.config['OIDC_ENABLED'] = is_enabled
|
||||
logger.info(f"[FACTORY OIDC_INIT] OIDC enabled status: {is_enabled}")
|
||||
|
||||
if is_enabled:
|
||||
# Apply same precedence logic to all OIDC settings
|
||||
provider_name = os.environ.get('OIDC_PROVIDER_NAME', oidc_db_settings.get('oidc_provider_name', 'oidc'))
|
||||
client_id = os.environ.get('OIDC_CLIENT_ID', oidc_db_settings.get('oidc_client_id', ''))
|
||||
client_secret = os.environ.get('OIDC_CLIENT_SECRET', oidc_db_settings.get('oidc_client_secret', ''))
|
||||
if os.environ.get('OIDC_CLIENT_SECRET_FILE'):
|
||||
client_secret = open(os.environ.get('OIDC_CLIENT_SECRET_FILE'), 'r').read().strip()
|
||||
issuer_url = os.environ.get('OIDC_ISSUER_URL', oidc_db_settings.get('oidc_issuer_url', ''))
|
||||
scope = os.environ.get('OIDC_SCOPE', oidc_db_settings.get('oidc_scope', 'openid email profile'))
|
||||
|
||||
current_app_instance.config['OIDC_PROVIDER_NAME'] = provider_name
|
||||
|
||||
if client_id and client_secret and issuer_url:
|
||||
logger.info(f"[FACTORY OIDC_INIT] Registering OIDC client '{provider_name}' with Authlib.")
|
||||
oauth.register(
|
||||
name=provider_name,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
server_metadata_url=f"{issuer_url.rstrip('/')}/.well-known/openid-configuration",
|
||||
client_kwargs={'scope': scope},
|
||||
override=True
|
||||
)
|
||||
logger.info(f"[FACTORY OIDC_INIT] OIDC client '{provider_name}' registered successfully.")
|
||||
else:
|
||||
logger.warning("[FACTORY OIDC_INIT] OIDC is enabled, but critical parameters are missing. OIDC login will be unavailable.")
|
||||
current_app_instance.config['OIDC_ENABLED'] = False
|
||||
else:
|
||||
current_app_instance.config['OIDC_PROVIDER_NAME'] = None
|
||||
logger.info("[FACTORY OIDC_INIT] OIDC is disabled.")
|
||||
|
||||
|
||||
@oidc_bp.route('/oidc/login') # Original path was /api/oidc/login
|
||||
def oidc_login_route():
|
||||
if not current_app.config.get('OIDC_ENABLED'):
|
||||
@@ -68,43 +136,80 @@ def oidc_callback_route():
|
||||
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
|
||||
return redirect(f"{frontend_login_url}?oidc_error=token_missing")
|
||||
|
||||
userinfo = token_data.get('userinfo')
|
||||
if not userinfo:
|
||||
try:
|
||||
userinfo = client.userinfo(token=token_data)
|
||||
except Exception as e:
|
||||
logger.error(f"[OIDC_HANDLER] OIDC callback error fetching userinfo: {e}")
|
||||
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
|
||||
return redirect(f"{frontend_login_url}?oidc_error=userinfo_fetch_failed")
|
||||
token_id_claims = token_data.get('userinfo')
|
||||
if not token_id_claims:
|
||||
logger.error("[OIDC_HANDLER] OIDC callback: Failed to retrieve token userinfo.")
|
||||
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
|
||||
return redirect(f"{frontend_login_url}?oidc_error=userinfo_missing")
|
||||
|
||||
try:
|
||||
userinfo = client.userinfo(token=token_data)
|
||||
except Exception as e:
|
||||
logger.error(f"[OIDC_HANDLER] OIDC callback error fetching userinfo: {e}")
|
||||
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
|
||||
return redirect(f"{frontend_login_url}?oidc_error=userinfo_fetch_failed")
|
||||
|
||||
if not userinfo:
|
||||
logger.error("[OIDC_HANDLER] OIDC callback: Failed to retrieve userinfo.")
|
||||
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
|
||||
return redirect(f"{frontend_login_url}?oidc_error=userinfo_missing")
|
||||
|
||||
oidc_subject = userinfo.get('sub')
|
||||
oidc_issuer = userinfo.get('iss')
|
||||
oidc_subject = token_id_claims.get('sub')
|
||||
oidc_issuer = token_id_claims.get('iss')
|
||||
|
||||
if not oidc_subject:
|
||||
logger.error("[OIDC_HANDLER] OIDC callback: 'sub' (subject) missing in userinfo.")
|
||||
logger.error("[OIDC_HANDLER] OIDC callback: 'sub' (subject) missing in token userinfo.")
|
||||
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
|
||||
return redirect(f"{frontend_login_url}?oidc_error=subject_missing")
|
||||
|
||||
email = token_id_claims.get('email') or userinfo.get('email')
|
||||
|
||||
first_name = token_id_claims.get('given_name') or userinfo.get('given_name', '')
|
||||
last_name = token_id_claims.get('family_name') or userinfo.get('family_name', '')
|
||||
|
||||
if not first_name and not last_name:
|
||||
first_name = token_id_claims.get('name') or userinfo.get('name', '')
|
||||
|
||||
user_groups = token_id_claims.get('groups') or userinfo.get('groups') or []
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
with conn.cursor() as cur:
|
||||
# Check for existing OIDC user
|
||||
cur.execute("SELECT id, username, email, is_admin FROM users WHERE oidc_sub = %s AND oidc_issuer = %s AND is_active = TRUE",
|
||||
cur.execute("SELECT id, username, email, first_name, last_name, is_admin FROM users WHERE oidc_sub = %s AND oidc_issuer = %s AND is_active = TRUE",
|
||||
(oidc_subject, oidc_issuer))
|
||||
user_db_data = cur.fetchone()
|
||||
|
||||
user_id = None
|
||||
is_new_user = False
|
||||
|
||||
admin_oidc_group = os.environ.get('OIDC_ADMIN_GROUP')
|
||||
if not admin_oidc_group:
|
||||
cur.execute("SELECT value FROM site_settings WHERE key = 'admin_oidc_group'")
|
||||
result = cur.fetchone()
|
||||
if result:
|
||||
admin_oidc_group = result[0]
|
||||
|
||||
if user_db_data:
|
||||
user_id = user_db_data[0]
|
||||
logger.info(f"[OIDC_HANDLER] Existing OIDC user found with ID {user_id} for sub {oidc_subject}")
|
||||
|
||||
# Update any changed user info
|
||||
if email and email != user_db_data[2]:
|
||||
cur.execute('UPDATE users SET email = %s WHERE id = %s', (email, user_id))
|
||||
logger.info(f"[OIDC_HANDLER] Updated email for OIDC user ID {user_id} to {email}")
|
||||
if first_name and first_name != user_db_data[3]:
|
||||
cur.execute('UPDATE users SET first_name = %s WHERE id = %s', (first_name, user_id))
|
||||
logger.info(f"[OIDC_HANDLER] Updated first name for OIDC user ID {user_id} to {first_name}")
|
||||
if last_name and last_name != user_db_data[4]:
|
||||
cur.execute('UPDATE users SET last_name = %s WHERE id = %s', (last_name, user_id))
|
||||
logger.info(f"[OIDC_HANDLER] Updated last name for OIDC user ID {user_id} to {last_name}")
|
||||
if admin_oidc_group:
|
||||
is_admin = admin_oidc_group in user_groups
|
||||
if is_admin != user_db_data[5]:
|
||||
cur.execute('UPDATE users SET is_admin = %s WHERE id = %s', (is_admin, user_id))
|
||||
logger.info(f"[OIDC_HANDLER] Updated admin status for OIDC user ID {user_id} to {is_admin} based on group membership.")
|
||||
else:
|
||||
# Check if registration is enabled before creating new users
|
||||
cur.execute("""
|
||||
@@ -136,7 +241,6 @@ def oidc_callback_route():
|
||||
|
||||
# New user provisioning
|
||||
is_new_user = True
|
||||
email = userinfo.get('email')
|
||||
if not email:
|
||||
logger.error("[OIDC_HANDLER] 'email' missing in userinfo for new OIDC user.")
|
||||
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
|
||||
@@ -149,36 +253,39 @@ def oidc_callback_route():
|
||||
frontend_login_url = os.environ.get('FRONTEND_URL', 'http://localhost:8080').rstrip('/') + "/login.html"
|
||||
return redirect(f"{frontend_login_url}?oidc_error=email_conflict_local_account")
|
||||
|
||||
username = userinfo.get('preferred_username') or userinfo.get('name') or email.split('@')[0]
|
||||
username = token_id_claims.get('preferred_username') or userinfo.get('preferred_username') or \
|
||||
token_id_claims.get('name') or userinfo.get('name') or \
|
||||
email.split('@')[0]
|
||||
# Ensure username uniqueness
|
||||
cur.execute("SELECT id FROM users WHERE username = %s", (username,))
|
||||
if cur.fetchone():
|
||||
username = f"{username}_{str(uuid.uuid4())[:4]}" # Short random suffix
|
||||
|
||||
first_name = userinfo.get('given_name', '')
|
||||
last_name = userinfo.get('family_name', '')
|
||||
|
||||
cur.execute('SELECT COUNT(*) FROM users')
|
||||
user_count = cur.fetchone()[0]
|
||||
|
||||
# Determine admin status: first user OR email matches configured admin email
|
||||
is_first_user_admin = (user_count == 0)
|
||||
|
||||
admin_email_from_env = current_app.config.get('ADMIN_EMAIL', '').lower()
|
||||
oidc_user_email_lower = email.lower() if email else ''
|
||||
|
||||
is_email_match_admin = False
|
||||
if admin_email_from_env and oidc_user_email_lower == admin_email_from_env:
|
||||
is_email_match_admin = True
|
||||
logger.info(f"[OIDC_HANDLER] New OIDC user email {oidc_user_email_lower} matches ADMIN_EMAIL {admin_email_from_env}.")
|
||||
|
||||
is_admin = is_first_user_admin or is_email_match_admin
|
||||
|
||||
if is_admin and not is_first_user_admin:
|
||||
logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} based on email match.")
|
||||
elif is_first_user_admin:
|
||||
logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} as they are the first user.")
|
||||
if admin_oidc_group:
|
||||
is_admin = admin_oidc_group in user_groups
|
||||
if is_admin:
|
||||
logger.info(f"[OIDC_HANDLER] New OIDC user {username} granted admin via OIDC group '{admin_oidc_group}'.")
|
||||
else:
|
||||
# Determine admin status: first user OR email matches configured admin email
|
||||
is_first_user_admin = (user_count == 0)
|
||||
|
||||
admin_email_from_env = current_app.config.get('ADMIN_EMAIL', '').lower()
|
||||
oidc_user_email_lower = email.lower() if email else ''
|
||||
|
||||
is_email_match_admin = False
|
||||
if admin_email_from_env and oidc_user_email_lower == admin_email_from_env:
|
||||
is_email_match_admin = True
|
||||
logger.info(f"[OIDC_HANDLER] New OIDC user email {oidc_user_email_lower} matches ADMIN_EMAIL {admin_email_from_env}.")
|
||||
|
||||
is_admin = is_first_user_admin or is_email_match_admin
|
||||
|
||||
if is_admin and not is_first_user_admin:
|
||||
logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} based on email match.")
|
||||
elif is_first_user_admin:
|
||||
logger.info(f"[OIDC_HANDLER] Granting admin rights to new OIDC user {oidc_user_email_lower} as they are the first user.")
|
||||
|
||||
# Insert new OIDC user
|
||||
cur.execute(
|
||||
@@ -193,14 +300,14 @@ def oidc_callback_route():
|
||||
app_session_token = generate_token(user_id) # Generate app-specific JWT
|
||||
|
||||
# Update last login timestamp
|
||||
cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.utcnow(), user_id))
|
||||
cur.execute('UPDATE users SET last_login = %s WHERE id = %s', (datetime.now(UTC), user_id))
|
||||
|
||||
# Log OIDC session in user_sessions table
|
||||
ip_address = request.remote_addr
|
||||
user_agent = request.headers.get('User-Agent', '')
|
||||
# Use a different UUID for session_token in DB if needed, or re-use app_session_token if appropriate for your session model
|
||||
db_session_token = str(uuid.uuid4())
|
||||
expires_at = datetime.utcnow() + current_app.config['JWT_EXPIRATION_DELTA']
|
||||
expires_at = datetime.now(UTC) + current_app.config['JWT_EXPIRATION_DELTA']
|
||||
|
||||
cur.execute(
|
||||
'INSERT INTO user_sessions (user_id, session_token, expires_at, ip_address, user_agent, login_method) VALUES (%s, %s, %s, %s, %s, %s)',
|
||||
|
||||
@@ -27,6 +27,7 @@ OIDC_CLIENT_ID=your_oidc_client_id
|
||||
OIDC_CLIENT_SECRET=your_oidc_client_secret
|
||||
OIDC_ISSUER_URL=https://your-oidc-provider.com/auth/realms/your-realm
|
||||
OIDC_SCOPE=openid email profile
|
||||
OIDC_ADMIN_GROUP=admin # Requires group scope
|
||||
|
||||
# Memory and Performance Settings
|
||||
WARRACKER_MEMORY_MODE=optimized
|
||||
|
||||
@@ -299,11 +299,12 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-menu-item">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<a href="about.html" style="text-decoration: none; color: inherit;" data-i18n="nav.about">About</a>
|
||||
<a href="about.html" style="text-decoration: none; color: inherit;">
|
||||
<i class="fas fa-info-circle"></i> <span data-i18n="nav.about">About</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-menu-item" id="logoutMenuItem">
|
||||
<i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span>
|
||||
<span><i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,13 +146,17 @@ header .container {
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
padding: 8px 15px !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.3s !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.user-menu-item > :first-child {
|
||||
padding: 8px 15px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
@@ -139,11 +139,12 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-menu-item">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<a href="about.html" style="text-decoration: none; color: inherit;" data-i18n="nav.about">About</a>
|
||||
<a href="about.html" style="text-decoration: none; color: inherit;">
|
||||
<i class="fas fa-info-circle"></i> <span data-i18n="nav.about">About</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-menu-item" id="logoutMenuItem">
|
||||
<i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span>
|
||||
<span><i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
}
|
||||
|
||||
header .user-menu-item {
|
||||
padding: 8px 15px !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.3s !important;
|
||||
display: flex !important;
|
||||
@@ -205,6 +204,11 @@
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
header .user-menu-item > :first-child {
|
||||
padding: 8px 15px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
header .user-menu-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
@@ -1151,4 +1155,4 @@
|
||||
font-size: 0.75rem !important;
|
||||
padding: 2px 5px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,11 +131,12 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-menu-item">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<a href="about.html" style="text-decoration: none; color: inherit;" data-i18n="nav.about">About</a>
|
||||
<a href="about.html" style="text-decoration: none; color: inherit;">
|
||||
<i class="fas fa-info-circle"></i> <span data-i18n="nav.about">About</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-menu-item" id="logoutMenuItem">
|
||||
<i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span>
|
||||
<span><i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,7 +415,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Security -->
|
||||
<div class="card">
|
||||
<div id="securitySection" class="card">
|
||||
<div class="card-header">
|
||||
<h3 data-i18n="settings.security">Security</h3>
|
||||
</div>
|
||||
@@ -633,6 +634,12 @@
|
||||
<input type="text" id="oidcScope" class="form-control" placeholder="e.g., openid email profile">
|
||||
<small class="text-muted" data-i18n="settings.scope_desc">Space-separated OIDC scopes.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="oidcAdminGroup" data-i18n="settings.oidc_admin_group">OIDC Admin Group</label>
|
||||
<input type="text" id="oidcAdminGroup" class="form-control" />
|
||||
<small class="text-muted" data-i18n="settings.oidc_admin_desc">Group that is given admin privileges. Leave blank to disable.</small>
|
||||
</div>
|
||||
|
||||
<button type="button" id="saveOidcSettingsBtn" class="btn btn-primary" data-i18n="settings.save_oidc_settings">Save OIDC Settings</button>
|
||||
<p id="oidcRestartMessage" class="text-muted" style="margin-top: 10px; display: none;" data-i18n="settings.oidc_restart_message">Application restart is required for OIDC settings to take full effect.</p>
|
||||
|
||||
@@ -65,6 +65,7 @@ const oidcClientIdInput = document.getElementById('oidcClientId');
|
||||
const oidcClientSecretInput = document.getElementById('oidcClientSecret');
|
||||
const oidcIssuerUrlInput = document.getElementById('oidcIssuerUrl');
|
||||
const oidcScopeInput = document.getElementById('oidcScope');
|
||||
const oidcAdminGroupInput = document.getElementById('oidcAdminGroup');
|
||||
const saveOidcSettingsBtn = document.getElementById('saveOidcSettingsBtn');
|
||||
const oidcRestartMessage = document.getElementById('oidcRestartMessage');
|
||||
|
||||
@@ -615,6 +616,20 @@ async function loadUserData() {
|
||||
}
|
||||
if (userNameDisplay) userNameDisplay.textContent = displayName;
|
||||
if (userEmailDisplay) userEmailDisplay.textContent = currentUser.email || 'N/A';
|
||||
|
||||
if (currentUser.oidc_managed) {
|
||||
if (firstNameInput) firstNameInput.disabled = true;
|
||||
if (lastNameInput) lastNameInput.disabled = true;
|
||||
if (emailInput) emailInput.disabled = true;
|
||||
if (saveProfileBtn) saveProfileBtn.style.display = 'none';
|
||||
userEditDesc = document.querySelector('#currentUserInfoDisplay > p > strong')
|
||||
if (userEditDesc) {
|
||||
userEditDesc.setAttribute('data-i18n', 'settings.current_user_oidc')
|
||||
userEditDesc.textContent = 'OIDC managed profile for:'
|
||||
}
|
||||
securitySection = document.getElementById('securitySection');
|
||||
if (securitySection) securitySection.style.display = 'none';
|
||||
}
|
||||
// --- END UPDATE ---
|
||||
|
||||
// Admin section visibility will be determined after API call
|
||||
@@ -3326,6 +3341,7 @@ async function loadSiteSettings() {
|
||||
const oidcClientSecretInputElem = document.getElementById('oidcClientSecret');
|
||||
const oidcIssuerUrlInputElem = document.getElementById('oidcIssuerUrl');
|
||||
const oidcScopeInputElem = document.getElementById('oidcScope');
|
||||
const oidcAdminGroupInputElem = document.getElementById('oidcAdminGroup');
|
||||
|
||||
try {
|
||||
showLoading();
|
||||
@@ -3429,6 +3445,13 @@ async function loadSiteSettings() {
|
||||
} else {
|
||||
console.error('[OIDC Settings] oidcScopeInputElem element NOT FOUND locally.');
|
||||
}
|
||||
|
||||
if (oidcAdminGroupInputElem) {
|
||||
console.log('[OIDC Settings] Found oidcAdminGroupInputElem. Setting value to:', settings.oidc_admin_group || '');
|
||||
oidcAdminGroupInputElem.value = settings.oidc_admin_group || '';
|
||||
} else {
|
||||
console.error('[OIDC Settings] oidcAdminGroupInputElem element NOT FOUND locally.');
|
||||
}
|
||||
|
||||
console.log('Site and OIDC settings loaded and population attempted using locally queried elements.');
|
||||
|
||||
@@ -3526,6 +3549,7 @@ async function saveOidcSettings() {
|
||||
oidc_client_id: oidcClientIdInput ? oidcClientIdInput.value.trim() : '',
|
||||
oidc_issuer_url: oidcIssuerUrlInput ? oidcIssuerUrlInput.value.trim() : '',
|
||||
oidc_scope: oidcScopeInput ? oidcScopeInput.value.trim() : 'openid email profile',
|
||||
oidc_admin_group: oidcAdminGroupInput ? oidcAdminGroupInput.value.trim() : ''
|
||||
};
|
||||
|
||||
// Only include client_secret if a new value is entered
|
||||
|
||||
@@ -344,11 +344,12 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-menu-item">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<a href="about.html" style="text-decoration: none; color: inherit;" data-i18n="nav.about">About</a>
|
||||
<a href="about.html" style="text-decoration: none; color: inherit;">
|
||||
<i class="fas fa-info-circle"></i> <span data-i18n="nav.about">About</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-menu-item" id="logoutMenuItem">
|
||||
<i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span>
|
||||
<span><i class="fas fa-sign-out-alt"></i> <span data-i18n="auth.logout">Logout</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -333,6 +333,7 @@ header .container {
|
||||
transition: color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
width: 25%; /* Ensure each tab is exactly 25% width */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-tab:hover {
|
||||
|
||||
Reference in New Issue
Block a user