diff --git a/application/single_app/admin_settings_int_utils.py b/application/single_app/admin_settings_int_utils.py new file mode 100644 index 00000000..70e41b5e --- /dev/null +++ b/application/single_app/admin_settings_int_utils.py @@ -0,0 +1,38 @@ +# admin_settings_int_utils.py + + +def safe_int_with_source(raw_value, fallback_value, hard_default=0): + """ + Safely parse an integer value using raw input, fallback, then hard default. + + Args: + raw_value (object): Primary value to parse. + fallback_value (object): Secondary value to parse when raw parsing fails. + hard_default (int): Final integer default when both values are invalid. + + Returns: + tuple[int, str]: Parsed integer and parse source (`raw`, `fallback`, `hard_default`). + """ + try: + return int(raw_value), "raw" + except (TypeError, ValueError): + try: + return int(fallback_value), "fallback" + except (TypeError, ValueError): + return int(hard_default), "hard_default" + + +def safe_int(raw_value, fallback_value, hard_default=0): + """ + Safely parse an integer using raw input, fallback, then hard default. + + Args: + raw_value (object): Primary value to parse. + fallback_value (object): Secondary value to parse when raw parsing fails. + hard_default (int): Final integer default when both values are invalid. + + Returns: + int: Parsed integer value. + """ + parsed_value, _ = safe_int_with_source(raw_value, fallback_value, hard_default) + return parsed_value diff --git a/application/single_app/app.py b/application/single_app/app.py index 2fba1631..9f62fbe6 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -425,10 +425,212 @@ def check_retention_policy(): # Unified session setup configure_sessions(settings) + +def get_idle_timeout_settings(settings=None): + """ + Resolve and normalize idle timeout settings used for warning and logout enforcement. + + Args: + settings (dict, optional): Settings dictionary to use. If None, uses request-scoped settings. + + Returns: + tuple[int, int]: A tuple of (idle_timeout_minutes, idle_warning_minutes) + after parsing, fallback handling, and boundary normalization. + + Raises: + None: Invalid values are handled via fallback defaults and warning logs. + """ + if settings is None: + settings = get_request_settings() + + timeout_raw = settings.get('idle_timeout_minutes', 30) + warning_raw = settings.get('idle_warning_minutes', 28) + + try: + timeout_minutes = int(timeout_raw) + except (TypeError, ValueError): + timeout_minutes = 30 + log_event( + "Invalid idle timeout value detected; using default.", + extra={ + "setting": "idle_timeout_minutes", + "raw_value": str(timeout_raw), + "fallback_value": 30 + }, + level=logging.WARNING + ) + + try: + warning_minutes = int(warning_raw) + except (TypeError, ValueError): + warning_minutes = 28 + log_event( + "Invalid idle warning value detected; using default.", + extra={ + "setting": "idle_warning_minutes", + "raw_value": str(warning_raw), + "fallback_value": 28 + }, + level=logging.WARNING + ) + + normalized_timeout = max(1, timeout_minutes) + if normalized_timeout != timeout_minutes: + log_event( + "Idle timeout value normalized to minimum allowed value.", + extra={ + "setting": "idle_timeout_minutes", + "original_value": timeout_minutes, + "normalized_value": normalized_timeout + }, + level=logging.WARNING + ) + timeout_minutes = normalized_timeout + + normalized_warning = max(0, warning_minutes) + if normalized_warning != warning_minutes: + log_event( + "Idle warning value normalized to minimum allowed value.", + extra={ + "setting": "idle_warning_minutes", + "original_value": warning_minutes, + "normalized_value": normalized_warning + }, + level=logging.WARNING + ) + warning_minutes = normalized_warning + + if warning_minutes >= timeout_minutes: + previous_warning_minutes = warning_minutes + warning_minutes = max(0, timeout_minutes - 1) + log_event( + "Idle warning value adjusted to remain below idle timeout.", + extra={ + "idle_timeout_minutes": timeout_minutes, + "original_idle_warning_minutes": previous_warning_minutes, + "adjusted_idle_warning_minutes": warning_minutes + }, + level=logging.WARNING + ) + + return timeout_minutes, warning_minutes + + +def is_idle_timeout_enabled(settings=None): + """ + Determine whether idle-timeout enforcement is enabled. + + Args: + settings (dict, optional): Settings dictionary to use. If None, uses request-scoped settings. + + Returns: + bool: True when idle-timeout enforcement should run; otherwise False. + + Raises: + None: Unexpected values are coerced to boolean-compatible behavior. + """ + if settings is None: + settings = get_request_settings() + + enabled_raw = settings.get('enable_idle_timeout', True) + + if isinstance(enabled_raw, str): + return enabled_raw.strip().lower() in ('1', 'true', 'yes', 'on') + + return bool(enabled_raw) + + +settings_source_counters = {} +settings_source_counters_lock = threading.Lock() + + +def record_request_settings_source(source): + """ + Record and log the source used to resolve request settings. + + Args: + source (str): Settings source label (for example: cache, cosmos_fallback, cosmos_forced). + + Returns: + None: Updates in-memory counters and request context diagnostics. + + Raises: + None: Counter updates and diagnostics are handled internally. + """ + normalized_source = source or 'unknown' + with settings_source_counters_lock: + settings_source_counters[normalized_source] = settings_source_counters.get(normalized_source, 0) + 1 + cache_hits = settings_source_counters.get('cache', 0) + cosmos_fallback_hits = settings_source_counters.get('cosmos_fallback', 0) + cosmos_forced_hits = settings_source_counters.get('cosmos_forced', 0) + unknown_hits = settings_source_counters.get('unknown', 0) + + g.request_settings_source = normalized_source + debug_print( + f"[SETTINGS SOURCE] path={request.path} source={normalized_source}", + category="SETTINGS", + cache_hits=cache_hits, + cosmos_fallback_hits=cosmos_fallback_hits, + cosmos_forced_hits=cosmos_forced_hits, + unknown_hits=unknown_hits + ) + + if normalized_source != 'cache': + log_event( + "Request settings source is non-cache.", + extra={ + "path": request.path, + "settings_source": normalized_source, + "cache_hits": cache_hits, + "cosmos_fallback_hits": cosmos_fallback_hits, + "cosmos_forced_hits": cosmos_forced_hits, + "unknown_hits": unknown_hits + }, + level=logging.INFO + ) + + +def get_request_settings(): + """ + Get request-scoped settings, resolving and caching them when needed. + + Args: + None + + Returns: + dict: Request settings dictionary cached on Flask `g` for the current request. + + Raises: + None: Unexpected resolver response shapes are logged and handled with safe fallbacks. + """ + request_settings = getattr(g, 'request_settings', None) + if request_settings is None: + settings_result = get_settings(include_source=True) + if isinstance(settings_result, tuple) and len(settings_result) == 2: + request_settings, settings_source = settings_result + else: + request_settings = settings_result + settings_source = 'unknown' + log_event( + "Unexpected settings response shape in get_request_settings.", + extra={ + "path": request.path, + "response_type": type(settings_result).__name__ + }, + level=logging.WARNING + ) + + request_settings = request_settings or {} + g.request_settings = request_settings + record_request_settings_source(settings_source) + return request_settings + @app.context_processor def inject_settings(): - settings = get_settings() + settings = get_request_settings() public_settings = sanitize_settings_for_user(settings) + idle_timeout_enabled = is_idle_timeout_enabled(settings) + idle_timeout_minutes, idle_warning_minutes = get_idle_timeout_settings(settings) # Inject per-user settings if logged in user_settings = {} try: @@ -440,7 +642,13 @@ def inject_settings(): print(f"Error injecting user settings: {e}") log_event(f"Error injecting user settings: {e}", level=logging.ERROR) user_settings = {} - return dict(app_settings=public_settings, user_settings=user_settings) + return dict( + app_settings=public_settings, + user_settings=user_settings, + idle_timeout_enabled=idle_timeout_enabled, + idle_timeout_minutes=idle_timeout_minutes, + idle_warning_minutes=idle_warning_minutes + ) @app.template_filter('to_datetime') def to_datetime_filter(value): @@ -464,6 +672,148 @@ def reload_kernel_if_needed(): """ setattr(builtins, "kernel_reload_needed", False) +IDLE_TIMEOUT_EXEMPT_PATHS = { + '/login', + '/logout', + '/logout/local', + '/getAToken', + '/getATokenApi', + '/robots933456.txt', + '/favicon.ico' +} + +IDLE_TIMEOUT_EXEMPT_PREFIXES = ( + '/static/', + '/health', + '/api/health' +) + +def _is_idle_timeout_exempt(path): + """ + Check whether a request path is exempt from idle-timeout processing. + + Args: + path (str): Request path to evaluate. + + Returns: + bool: True if the path is exempt from idle-timeout checks; otherwise False. + + Raises: + None + """ + if path in IDLE_TIMEOUT_EXEMPT_PATHS: + return True + return any(path.startswith(prefix) for prefix in IDLE_TIMEOUT_EXEMPT_PREFIXES) + + +@app.before_request +def load_request_settings_cache(): + """ + Preload request-scoped settings for authenticated, non-exempt requests. + + Args: + None + + Returns: + None: Always returns None to continue Flask request processing. + + Raises: + None: Unexpected settings resolver shapes are logged and converted to safe fallbacks. + """ + g.request_settings = None + g.request_settings_source = None + + if request.method == 'OPTIONS' or _is_idle_timeout_exempt(request.path): + return None + + if 'user' not in session: + return None + + settings_result = get_settings(include_source=True) + if isinstance(settings_result, tuple) and len(settings_result) == 2: + request_settings, settings_source = settings_result + else: + request_settings = settings_result + settings_source = 'unknown' + log_event( + "Unexpected settings response shape in load_request_settings_cache.", + extra={ + "path": request.path, + "response_type": type(settings_result).__name__ + }, + level=logging.WARNING + ) + + g.request_settings = request_settings or {} + record_request_settings_source(settings_source) + return None + +@app.before_request +def enforce_idle_session_timeout(): + """ + Enforce server-side idle session timeout for authenticated requests. + + Args: + None + + Returns: + Response | None: A redirect/401 response when timeout is exceeded; otherwise None. + + Raises: + None: Runtime issues in timeout evaluation are logged and request processing continues safely. + """ + if 'user' not in session: + return None + + if request.method == 'OPTIONS' or _is_idle_timeout_exempt(request.path): + return None + + now_epoch = int(time.time()) + request_settings = get_request_settings() + if not is_idle_timeout_enabled(request_settings): + session['last_activity_epoch'] = now_epoch + session.modified = True + return None + + idle_timeout_minutes, _ = get_idle_timeout_settings(request_settings) + last_activity_epoch = session.get('last_activity_epoch') + has_valid_last_activity_epoch = False + + if last_activity_epoch is not None: + try: + parsed_last_activity_epoch = int(float(last_activity_epoch)) + has_valid_last_activity_epoch = True + idle_seconds = now_epoch - parsed_last_activity_epoch + if idle_seconds >= (idle_timeout_minutes * 60): + user_id = session.get('user', {}).get('oid') or session.get('user', {}).get('sub') + session.clear() + + log_event( + f"Session expired due to {idle_timeout_minutes} minute inactivity timeout for user {user_id or 'unknown'}.", + level=logging.INFO + ) + + if request.path.startswith('/api/'): + return jsonify({ + 'error': 'Session expired', + 'message': 'Your session expired due to inactivity. Please sign in again.', + 'requires_reauth': True + }), 401 + + return redirect(url_for('local_logout')) + except Exception as e: + log_event(f"Idle timeout evaluation failed: {e}", level=logging.WARNING) + + if request.path.startswith('/api/'): + if not has_valid_last_activity_epoch: + session['last_activity_epoch'] = now_epoch + session.modified = True + return None + + session['last_activity_epoch'] = now_epoch + session.modified = True + return None + @app.after_request def add_security_headers(response): """ @@ -556,6 +906,30 @@ def serve_js_modules(filename): def acceptable_use_policy(): return render_template('acceptable_use_policy.html') +@app.route('/api/session/heartbeat', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +def session_heartbeat(): + """ + Refresh the authenticated session activity timestamp used by idle-timeout enforcement. + + Args: + None + + Returns: + tuple[Response, int]: JSON response containing refresh confirmation and timeout metadata. + + Raises: + None + """ + session['last_activity_epoch'] = int(time.time()) + session.modified = True + idle_timeout_minutes, _ = get_idle_timeout_settings(get_request_settings()) + return jsonify({ + 'message': 'Session refreshed', + 'idle_timeout_minutes': idle_timeout_minutes + }), 200 + @app.route('/api/semantic-kernel/plugins') @swagger_route(security=get_auth_security()) def list_semantic_kernel_plugins(): diff --git a/application/single_app/config.py b/application/single_app/config.py index 2280b761..d7820228 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.239.011" +VERSION = "0.239.012" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -562,7 +562,7 @@ def ensure_custom_favicon_file_exists(app, settings): except (base64.binascii.Error, TypeError, OSError) as ex: # Catch specific errors print(f"Failed to write/overwrite {favicon_filename}: {ex}") except Exception as ex: # Catch any other unexpected errors - print(f"Unexpected error during favicon file write for {favicon_filename}: {ex}") + print(f"Unexpected error during favicon file write for {favicon_filename}: {ex}") def initialize_clients(settings): """ diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 89367065..c8d97a47 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -4,8 +4,9 @@ from functions_appinsights import log_event import app_settings_cache import inspect +import copy -def get_settings(use_cosmos=False): +def get_settings(use_cosmos=False, include_source=False): import secrets default_settings = { # External health check @@ -259,6 +260,9 @@ def get_settings(use_cosmos=False): # Other 'max_file_size_mb': 150, 'conversation_history_limit': 10, + 'enable_idle_timeout': True, + 'idle_timeout_minutes': 30, + 'idle_warning_minutes': 28, 'default_system_prompt': '', # Access denied message shown on the home page for signed-in users who lack required roles. # Default is hard-coded; admins can override via Admin Settings (persisted in Cosmos DB). @@ -326,6 +330,11 @@ def get_settings(use_cosmos=False): 'default_retention_document_public': 'none', } + def _format_result(settings_payload, source): + if include_source: + return settings_payload, source + return settings_payload + try: # Attempt to read the existing doc if use_cosmos: @@ -333,17 +342,35 @@ def get_settings(use_cosmos=False): item="app_settings", partition_key="app_settings" ) + settings_source = "cosmos_forced" + log_event( + "App settings loaded from Cosmos DB (forced).", + extra={ + "settings_source": settings_source, + "use_cosmos": True + }, + level=logging.INFO + ) else: settings_item = None + settings_source = "cache" cache_accessor = getattr(app_settings_cache, "get_settings_cache", None) if callable(cache_accessor): try: settings_item = cache_accessor() - except Exception: + except Exception as cache_error: settings_item = None + log_event( + "Error reading app settings from cache accessor.", + extra={ + "error": str(cache_error) + }, + level=logging.WARNING + ) if not settings_item: + settings_source = "cosmos_fallback" settings_item = cosmos_settings_container.read_item( item="app_settings", partition_key="app_settings" @@ -361,33 +388,75 @@ def get_settings(use_cosmos=False): "Warning: Failed to get settings from cache, read from Cosmos DB instead. " f"Called from {caller_file}:{caller_line} in {caller_func}()." ) + log_event( + "App settings cache miss. Falling back to Cosmos DB.", + extra={ + "settings_source": settings_source, + "caller_file": caller_file, + "caller_line": caller_line, + "caller_func": caller_func + }, + level=logging.WARNING + ) else: print( "Warning: Failed to get settings from cache, " "read from Cosmos DB instead. (no caller frame)" ) + log_event( + "App settings cache miss. Falling back to Cosmos DB (no caller frame).", + extra={ + "settings_source": settings_source + }, + level=logging.WARNING + ) #print("Successfully retrieved settings from Cosmos DB.") + original_settings_item = copy.deepcopy(settings_item) + # Merge default_settings in, to fill in any missing or nested keys merged = deep_merge_dicts(default_settings, settings_item) # If merging added anything new, upsert back to Cosmos so future reads remain up to date - if merged != settings_item: + if merged != original_settings_item: cosmos_settings_container.upsert_item(merged) print("App Settings had missing keys and was updated in Cosmos DB.") - return merged + log_event( + "App settings missing keys were merged and persisted to Cosmos DB.", + extra={ + "settings_source": settings_source + }, + level=logging.INFO + ) + return _format_result(merged, settings_source) else: # If merged is unchanged, no new keys needed - return merged + return _format_result(merged, settings_source) except CosmosResourceNotFoundError: cosmos_settings_container.create_item(body=default_settings) print("Default settings created in Cosmos and returned.") - return default_settings + log_event( + "App settings document not found. Default settings created in Cosmos DB.", + extra={ + "settings_source": "cosmos_default_created" + }, + level=logging.WARNING + ) + return _format_result(default_settings, "cosmos_default_created") except Exception as e: print(f"Error retrieving settings: {str(e)}") - return None + log_event( + "Error retrieving app settings.", + extra={ + "error": str(e), + "use_cosmos": use_cosmos + }, + level=logging.ERROR, + exceptionTraceback=True + ) + return _format_result(None, "error") def update_settings(new_settings): try: diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 2fc5abc8..70805a7f 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -8,6 +8,7 @@ from functions_logging import * from swagger_wrapper import swagger_route, get_auth_security from datetime import datetime, timedelta +from admin_settings_int_utils import safe_int_with_source def allowed_file(filename, allowed_extensions): return '.' in filename and \ @@ -288,10 +289,58 @@ def admin_settings(): form_data = request.form # Use a variable for easier access user_id = get_current_user_id() + def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_default=0): + """ + Parse an admin form value to an integer with structured fallback diagnostics. + + Args: + raw_value (object): The submitted form value to parse. + fallback_value (object): The fallback value to parse when input conversion fails. + field_name (str): The admin settings field name being parsed. + hard_default (int): Final integer default when both input and fallback are invalid. + + Returns: + int: A valid integer derived from input, fallback, or hard default. + Raises: + None. + """ + parsed_value, parse_source = safe_int_with_source(raw_value, fallback_value, hard_default) + + if parse_source == "hard_default": + log_event( + "Invalid admin settings integer input and fallback detected; using hard default value.", + extra={ + "field": field_name, + "raw_value": str(raw_value), + "fallback_value": str(fallback_value), + "hard_default": hard_default, + "user_id": user_id + }, + level=logging.WARNING + ) + elif parse_source == "fallback": + log_event( + "Invalid admin settings integer input detected; using fallback value.", + extra={ + "field": field_name, + "raw_value": str(raw_value), + "fallback_value": str(fallback_value), + "user_id": user_id + }, + level=logging.WARNING + ) + + return parsed_value + # --- Fetch all other form data as before --- app_title = form_data.get('app_title', 'AI Chat Application') max_file_size_mb = int(form_data.get('max_file_size_mb', 16)) conversation_history_limit = int(form_data.get('conversation_history_limit', 10)) + enable_idle_timeout = form_data.get('enable_idle_timeout') == 'on' + idle_timeout_minutes = max(1, parse_admin_int(form_data.get('idle_timeout_minutes'), settings.get('idle_timeout_minutes', 30), 'idle_timeout_minutes', 30)) + idle_warning_minutes = max(0, parse_admin_int(form_data.get('idle_warning_minutes'), settings.get('idle_warning_minutes', 28), 'idle_warning_minutes', 28)) + if idle_warning_minutes >= idle_timeout_minutes: + idle_warning_minutes = max(0, idle_timeout_minutes - 1) # ... (fetch all other fields using form_data.get) ... enable_video_file_support = form_data.get('enable_video_file_support') == 'on' enable_audio_file_support = form_data.get('enable_audio_file_support') == 'on' @@ -868,6 +917,9 @@ def is_valid_url(url): # Other 'max_file_size_mb': max_file_size_mb, 'conversation_history_limit': conversation_history_limit, + 'enable_idle_timeout': enable_idle_timeout, + 'idle_timeout_minutes': idle_timeout_minutes, + 'idle_warning_minutes': idle_warning_minutes, 'default_system_prompt': form_data.get('default_system_prompt', '').strip(), 'access_denied_message': form_data.get('access_denied_message', settings.get('access_denied_message', '')).strip(), diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index 022ecf84..a60f7734 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -35,6 +35,7 @@ def login(): # Clear potentially stale cache/user info before starting new login session.pop("user", None) session.pop("token_cache", None) + session.pop("last_activity_epoch", None) # Use helper to build app (cache not strictly needed here, but consistent) msal_app = _build_msal_app() @@ -121,6 +122,7 @@ def authorized(): debug_print(f" [claims] User claims: {result.get('id_token_claims', {})}") session["user"] = result.get("id_token_claims") + session["last_activity_epoch"] = int(time.time()) # --- CRITICAL: Save the entire cache (contains tokens) to session --- _save_cache(msal_app.token_cache) @@ -207,6 +209,39 @@ def authorized_api(): return jsonify(result, 200) + @app.route('/logout/local') + @swagger_route(security=get_auth_security()) + def local_logout(): + """ + Clear the local Flask session and redirect to the configured home destination. + + Args: + None. + + Returns: + Response: A redirect response to the local or Front Door home URL. + Raises: + None. + """ + session.clear() + + from functions_settings import get_settings + settings = get_settings() + + if settings.get('enable_front_door', False): + front_door_url = settings.get('front_door_url') + if front_door_url: + home_url, _ = build_front_door_urls(front_door_url) + logout_uri = home_url + elif HOME_REDIRECT_URL: + logout_uri = HOME_REDIRECT_URL + else: + logout_uri = url_for('index', _external=True, _scheme='https') + else: + logout_uri = url_for('index', _external=True, _scheme='https') + + return redirect(logout_uri) + @app.route('/logout') @swagger_route(security=get_auth_security()) def logout(): diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index f672b28f..c7f30dfe 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1046,7 +1046,7 @@ a.citation-link:hover { .message-content { display: flex; align-items: flex-end; - overflow: auto; /* Preserving higher level visible property while allowing response message scroll if needed */ + overflow: auto; /* Make message content scrollable when it overflows while minimizing effects elsewhere. */ } .message-content.flex-row-reverse { diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index c6bdec36..7cc02735 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -1538,6 +1538,16 @@ function setupToggles() { }); } + const enableIdleTimeoutToggle = document.getElementById('enable_idle_timeout'); + const idleTimeoutSettingsDiv = document.getElementById('idle_timeout_settings'); + if (enableIdleTimeoutToggle && idleTimeoutSettingsDiv) { + idleTimeoutSettingsDiv.classList.toggle('d-none', !enableIdleTimeoutToggle.checked); + enableIdleTimeoutToggle.addEventListener('change', function () { + idleTimeoutSettingsDiv.classList.toggle('d-none', !this.checked); + markFormAsModified(); + }); + } + const enableEnhancedCitation = document.getElementById('enable_enhanced_citations'); if (enableEnhancedCitation) { toggleEnhancedCitation(enableEnhancedCitation.checked); diff --git a/application/single_app/static/js/idle-logout-warning.js b/application/single_app/static/js/idle-logout-warning.js new file mode 100644 index 00000000..bc5e804b --- /dev/null +++ b/application/single_app/static/js/idle-logout-warning.js @@ -0,0 +1,233 @@ +// idle-logout-warning.js + +(function () { + 'use strict'; + + const defaultConfig = { + enabled: false, + timeoutMinutes: 30, + warningMinutes: 28, + heartbeatUrl: '/api/session/heartbeat', + localLogoutUrl: '/logout/local', + fullSsoLogoutUrl: '/logout', + logoutUrl: '/logout' + }; + + const mergedConfig = Object.assign({}, defaultConfig, window.idleLogoutConfig || {}); + + if (!mergedConfig.enabled) { + return; + } + + document.addEventListener('DOMContentLoaded', function () { + if (typeof bootstrap === 'undefined' || !bootstrap.Modal) { + return; + } + + const warningModalElement = document.getElementById('idleTimeoutWarningModal'); + const countdownElement = document.getElementById('idleTimeoutCountdown'); + const staySignedInButton = document.getElementById('idleStaySignedInButton'); + const logoutNowButton = document.getElementById('idleLogoutNowButton'); + + if (!warningModalElement || !countdownElement || !staySignedInButton || !logoutNowButton) { + return; + } + + const timeoutMinutes = Number(mergedConfig.timeoutMinutes); + const warningMinutes = Number(mergedConfig.warningMinutes); + + if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) { + return; + } + + if (!Number.isFinite(warningMinutes) || warningMinutes < 0 || warningMinutes >= timeoutMinutes) { + return; + } + + const timeoutMs = timeoutMinutes * 60 * 1000; + const warningMs = warningMinutes * 60 * 1000; + const warningModal = bootstrap.Modal.getOrCreateInstance(warningModalElement); + + let warningTimer = null; + let logoutTimer = null; + let countdownInterval = null; + let logoutDeadlineMs = null; + let isRefreshingSession = false; + let lastActivityResetAt = 0; + let lastServerHeartbeatAt = 0; + + const HEARTBEAT_MIN_INTERVAL_MS = Math.min(60000, timeoutMs / 2); + + const activityEvents = [ + 'mousedown', + 'mousemove', + 'keydown', + 'scroll', + 'touchstart', + 'click' + ]; + + function clearTimers() { + if (warningTimer) { + clearTimeout(warningTimer); + warningTimer = null; + } + + if (logoutTimer) { + clearTimeout(logoutTimer); + logoutTimer = null; + } + } + + function stopCountdown() { + if (countdownInterval) { + clearInterval(countdownInterval); + countdownInterval = null; + } + } + + function updateCountdown() { + if (!logoutDeadlineMs) { + return; + } + + const remainingMs = Math.max(0, logoutDeadlineMs - Date.now()); + const remainingSeconds = Math.ceil(remainingMs / 1000); + countdownElement.textContent = String(remainingSeconds); + } + + function startCountdown() { + stopCountdown(); + updateCountdown(); + countdownInterval = setInterval(updateCountdown, 1000); + } + + function hideWarningModal() { + if (warningModalElement.classList.contains('show')) { + warningModal.hide(); + } + stopCountdown(); + } + + function logoutNow() { + clearTimers(); + stopCountdown(); + const logoutTarget = mergedConfig.localLogoutUrl || mergedConfig.fullSsoLogoutUrl || mergedConfig.logoutUrl; + window.location.href = logoutTarget; + } + + function scheduleIdleTimers() { + clearTimers(); + logoutDeadlineMs = Date.now() + timeoutMs; + + warningTimer = setTimeout(function () { + warningModal.show(); + startCountdown(); + }, warningMs); + + logoutTimer = setTimeout(logoutNow, timeoutMs); + } + + async function refreshServerSession(forceLogoutOnFailure, userInitiated) { + if (isRefreshingSession) { + return; + } + + isRefreshingSession = true; + if (userInitiated) { + staySignedInButton.disabled = true; + } + + try { + const response = await fetch(mergedConfig.heartbeatUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: '{}' + }); + + if (!response.ok) { + let requiresReauth = response.status === 401 || response.status === 403; + + if (!requiresReauth) { + try { + const responseBody = await response.clone().json(); + requiresReauth = Boolean(responseBody && responseBody.requires_reauth); + } catch (_parseError) { + requiresReauth = false; + } + } + + if (forceLogoutOnFailure || requiresReauth) { + logoutNow(); + } + return; + } + + lastServerHeartbeatAt = Date.now(); + + if (userInitiated) { + hideWarningModal(); + scheduleIdleTimers(); + } + } catch (error) { + console.error('Session heartbeat failed:', error); + if (forceLogoutOnFailure) { + logoutNow(); + } + } finally { + isRefreshingSession = false; + if (userInitiated) { + staySignedInButton.disabled = false; + } + } + } + + staySignedInButton.addEventListener('click', function () { + refreshServerSession(true, true); + }); + + logoutNowButton.addEventListener('click', function () { + logoutNow(); + }); + + warningModalElement.addEventListener('hidden.bs.modal', function () { + stopCountdown(); + }); + + activityEvents.forEach(function (eventName) { + document.addEventListener(eventName, handleUserActivity); + }); + + document.addEventListener('visibilitychange', function () { + if (!document.hidden) { + handleUserActivity(); + } + }); + + scheduleIdleTimers(); + + function handleUserActivity() { + const now = Date.now(); + + if (now - lastActivityResetAt < 1000) { + return; + } + + lastActivityResetAt = now; + + if (warningModalElement.classList.contains('show')) { + hideWarningModal(); + } + + scheduleIdleTimers(); + + if ((now - lastServerHeartbeatAt) >= HEARTBEAT_MIN_INTERVAL_MS) { + refreshServerSession(false, false); + } + } + }); +})(); diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index e9ff7d18..c649049a 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -1423,6 +1423,42 @@
+
+ + + +
+
+
+ + + Users are logged out locally after this many minutes of inactivity. +
+
+ + + Show the warning modal after this many minutes of inactivity. (Countdown starts from this time) +
+