From 89f4a867bae0c61677a894e9ed11deded0f12c63 Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Thu, 5 Mar 2026 01:02:34 -0500 Subject: [PATCH 1/2] feat: admin-configurable access denied message (#557) - Add access_denied_message to default_settings in functions_settings.py - Persist access_denied_message from Admin Settings form in route_frontend_admin_settings.py - Add Access Denied Message textarea to Admin Settings UI - Render dynamic message on index.html for signed-in users lacking required roles Fix Copilot PR #557 findings: - Finding 1: default message in functions_settings.py now matches index.html fallback (both use 'Please contact an administrator for access.') - Finding 2: replaced '| e | replace(\\n,
) | safe' filter chain with a proper nl2br Jinja filter registered in app.py, avoiding potential filter-order confusion flagged by Copilot review --- application/single_app/app.py | 10 ++++++++++ application/single_app/config.py | 2 +- application/single_app/functions_settings.py | 3 +++ .../single_app/route_frontend_admin_settings.py | 1 + application/single_app/templates/admin_settings.html | 6 ++++++ application/single_app/templates/index.html | 3 +-- 6 files changed, 22 insertions(+), 3 deletions(-) diff --git a/application/single_app/app.py b/application/single_app/app.py index 2354b1b5..d58eeed5 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -487,6 +487,16 @@ def markdown_filter(text): # Add the filter to the Jinja environment app.jinja_env.filters['markdown'] = markdown_filter +# Register a custom Jinja filter for nl2br (newline to
) +def nl2br_filter(value): + """Escape HTML then convert newline characters to
tags.""" + from markupsafe import escape, Markup + if not value: + return Markup('') + return Markup(str(escape(value)).replace('\n', '
\n')) + +app.jinja_env.filters['nl2br'] = nl2br_filter + # =================== Default Routes ===================== @app.route('/') @swagger_route(security=get_auth_security()) diff --git a/application/single_app/config.py b/application/single_app/config.py index 91288225..40d17d3c 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.002" +VERSION = "0.239.003" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 8176939d..89367065 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -260,6 +260,9 @@ def get_settings(use_cosmos=False): 'max_file_size_mb': 150, 'conversation_history_limit': 10, '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). + 'access_denied_message': 'You are logged in but do not have the required permissions to access this application.\nPlease contact an administrator for access.', 'enable_file_processing_logs': True, 'file_processing_logs_timer_enabled': False, 'file_timer_value': 1, diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 578e1545..bf5c8077 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -869,6 +869,7 @@ def is_valid_url(url): 'max_file_size_mb': max_file_size_mb, 'conversation_history_limit': conversation_history_limit, 'default_system_prompt': form_data.get('default_system_prompt', '').strip(), + 'access_denied_message': form_data.get('access_denied_message', '').strip(), # Video file settings with Azure Video Indexer Settings 'video_indexer_endpoint': form_data.get('video_indexer_endpoint', video_indexer_endpoint).strip(), diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 7d01f7da..f8c8b623 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -1428,6 +1428,12 @@
+
+ + Shown to signed-in users who lack the required roles. Use Enter for line breaks. + +
diff --git a/application/single_app/templates/index.html b/application/single_app/templates/index.html index 7a146e0d..4d415f4d 100644 --- a/application/single_app/templates/index.html +++ b/application/single_app/templates/index.html @@ -62,8 +62,7 @@ {% else %} {% if session.get('user') %}

- You are logged in but do not have the required permissions to access this application. - Please submit a ticket to request access. + {{ (app_settings.access_denied_message or 'You are logged in but do not have the required permissions to access this application.\nPlease contact an administrator for access.') | nl2br }}

{% else %}
From dc29406d2b536790e191d5442e447905e7757a9c Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Thu, 5 Mar 2026 13:14:11 -0500 Subject: [PATCH 2/2] fix: address Copilot PR review findings for access denied message feature Finding 1 (index.html redundant fallback): - Removed hardcoded 'or ...' fallback from access_denied_message rendering in templates/index.html; default lives exclusively in functions_settings.py and is guaranteed present after get_settings() deep-merge Finding 2 (route silent data loss): - Changed access_denied_message in route_frontend_admin_settings.py to fall back to settings.get('access_denied_message', '') instead of '' so an older/cached form submission that omits the field does not wipe the existing stored value Finding 3 (missing functional regression test): - Added functional_tests/test_access_denied_message_feature.py (4/4 passing): (1) admin_settings.html exposes textarea name=access_denied_message with label (2) route uses safe settings.get() fallback, not bare empty string (3) index.html renders via nl2br with no inline hardcoded fallback (4) functions_settings.py defines a non-empty default value --- .../route_frontend_admin_settings.py | 2 +- application/single_app/templates/index.html | 2 +- .../test_access_denied_message_feature.py | 197 ++++++++++++++++++ 3 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 functional_tests/test_access_denied_message_feature.py diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index bf5c8077..2fc5abc8 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -869,7 +869,7 @@ def is_valid_url(url): 'max_file_size_mb': max_file_size_mb, 'conversation_history_limit': conversation_history_limit, 'default_system_prompt': form_data.get('default_system_prompt', '').strip(), - 'access_denied_message': form_data.get('access_denied_message', '').strip(), + 'access_denied_message': form_data.get('access_denied_message', settings.get('access_denied_message', '')).strip(), # Video file settings with Azure Video Indexer Settings 'video_indexer_endpoint': form_data.get('video_indexer_endpoint', video_indexer_endpoint).strip(), diff --git a/application/single_app/templates/index.html b/application/single_app/templates/index.html index 4d415f4d..c3c2abc6 100644 --- a/application/single_app/templates/index.html +++ b/application/single_app/templates/index.html @@ -62,7 +62,7 @@ {% else %} {% if session.get('user') %}

- {{ (app_settings.access_denied_message or 'You are logged in but do not have the required permissions to access this application.\nPlease contact an administrator for access.') | nl2br }} + {{ app_settings.access_denied_message | nl2br }}

{% else %}
diff --git a/functional_tests/test_access_denied_message_feature.py b/functional_tests/test_access_denied_message_feature.py new file mode 100644 index 00000000..4a0a2194 --- /dev/null +++ b/functional_tests/test_access_denied_message_feature.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# test_access_denied_message_feature.py +""" +Functional regression test for admin-configurable access denied message. + +Version: 0.239.002 +Implemented in: 0.239.002 + +This test ensures that: +1. The Admin Settings template exposes a textarea with name="access_denied_message". +2. route_frontend_admin_settings.py reads the field from form_data and falls back + to the existing stored value (not '') when the field is absent -- preventing + silent data loss from cached/older form submissions. +3. index.html renders app_settings.access_denied_message through the nl2br filter + without a redundant hardcoded fallback string. +4. functions_settings.py defines a non-empty default for access_denied_message so + the field is always present after get_settings() deep-merges defaults. +""" + +import sys +import os +import re + +# Resolve paths relative to repo root +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +ADMIN_TEMPLATE = os.path.join(REPO_ROOT, "application", "single_app", "templates", "admin_settings.html") +INDEX_TEMPLATE = os.path.join(REPO_ROOT, "application", "single_app", "templates", "index.html") +ROUTE_FILE = os.path.join(REPO_ROOT, "application", "single_app", "route_frontend_admin_settings.py") +SETTINGS_FILE = os.path.join(REPO_ROOT, "application", "single_app", "functions_settings.py") + + +# --------------------------------------------------------------------------- +# Test 1 – Admin Settings template has the access_denied_message field +# --------------------------------------------------------------------------- + +def test_admin_template_has_field(): + """Admin Settings template must expose a textarea named access_denied_message.""" + print("Testing admin_settings.html contains access_denied_message field...") + errors = [] + + with open(ADMIN_TEMPLATE, encoding="utf-8") as f: + content = f.read() + + # textarea with correct name attribute + if 'name="access_denied_message"' not in content: + errors.append("No