Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions application/single_app/static/js/admin/admin_custom_pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,11 @@
appendTextCell(row, page.show_in_nav ? "Shown" : "Hidden");

const actionCell = document.createElement("td");
actionCell.className = "text-nowrap";
const isPythonPage = page.source === "python";

actionCell.appendChild(createCustomPageOpenAction(page));

const editButton = document.createElement("button");
editButton.type = "button";
editButton.className = "btn btn-sm btn-outline-primary me-1";
Expand All @@ -219,6 +222,59 @@
syncRequestAccessPageButtonState();
}

function createCustomPageOpenAction(page) {
const unavailableReason = getCustomPageOpenUnavailableReason(page);
if (unavailableReason) {
const disabledWrapper = document.createElement("span");

Check warning on line 228 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledWrapper.className = "d-inline-block me-1";

Check warning on line 229 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledWrapper.title = unavailableReason;

Check warning on line 230 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledWrapper.tabIndex = 0;

Check warning on line 231 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledWrapper.setAttribute("aria-label", unavailableReason);

Check warning on line 232 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.

const disabledButton = document.createElement("button");

Check warning on line 234 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledButton.type = "button";

Check warning on line 235 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledButton.className = "btn btn-sm btn-outline-secondary";

Check warning on line 236 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledButton.disabled = true;

Check warning on line 237 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledButton.textContent = "Open";

Check warning on line 238 in application/single_app/static/js/admin/admin_custom_pages.js

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
disabledWrapper.appendChild(disabledButton);
return disabledWrapper;
}

const openLink = document.createElement("a");
openLink.className = "btn btn-sm btn-outline-success me-1";
openLink.href = getCustomPageUrl(page);
openLink.target = "_blank";
openLink.rel = "noopener noreferrer";
openLink.textContent = "Open";
openLink.title = "Open this custom page in a new tab. Route authorization still applies.";
return openLink;
}

function getCustomPageOpenUnavailableReason(page) {
if (window.customPagesInitiallyEnabled !== true) {
return "Enable Custom Pages, save settings, and restart the app before opening custom pages.";
}

if (!getCustomPageSlug(page)) {
return "This custom page cannot be opened because it is missing a slug.";
}

if (page?.enabled === false) {
return "Disabled custom pages are not available to open.";
}

return "";
}

function getCustomPageUrl(page) {
const slug = getCustomPageSlug(page);
return slug ? `/custom/${encodeURIComponent(slug)}` : "";
}

function getCustomPageSlug(page) {
return String(page?.slug || "").trim();
}

function requestAccessPageExists() {
return customPages.some(page => (page.slug || "").toLowerCase() === "request-access");
}
Expand Down
11 changes: 9 additions & 2 deletions docs/explanation/features/CUSTOM_PAGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Custom Pages

Current version: **0.242.040**
Current version: **0.250.106**

Implemented in version: **0.242.023**

Expand Down Expand Up @@ -34,6 +34,8 @@ Request Access guidance modal and duplicate slug checks implemented in version:

Authenticated custom page navigation fix implemented in version: **0.242.040**

Admin Settings Open action implemented in version: **0.250.106**

## Overview

Custom Pages let app teams that deploy SimpleChat add trusted, deployment-time pages under the `/custom` namespace without changing the core application route code. The feature is controlled by the Admin Settings toggle `enable_custom_pages` and fails closed when disabled.
Expand All @@ -44,6 +46,7 @@ Custom Pages let app teams that deploy SimpleChat add trusted, deployment-time p
- Cosmos DB metadata container `custom_pages`, configured in `application/single_app/config.py`
- Static file folders under `application/single_app/custom_pages/`: `assets`, `css`, `html`, `js`, `json`, and `python`
- Admin Settings metadata designer in `application/single_app/templates/admin_settings.html`
- Admin Settings table actions in `application/single_app/static/js/admin/admin_custom_pages.js`

## Technical Specifications

Expand Down Expand Up @@ -97,10 +100,14 @@ Version `0.242.037` added post-create guidance for editing the Request Access em

Version `0.242.040` changed navigation rendering so Custom Pages appears for any signed-in user only when `custom_pages_nav` contains at least one enabled, visible, authorized page. This allows `access_level=authenticated` pages such as Request Access to render for signed-in users without `User` or `Admin`, while hiding the pane when no custom page is available.

Version `0.250.106` added an Admin Settings table Open action for each configured custom page. The action builds an encoded `/custom/<slug>` URL and opens it in a new tab so the existing custom page routes continue to enforce feature enablement, per-page enabled state, access level, allowed roles, and `.html` alias compatibility. Disabled pages and pages unavailable because Custom Pages is not enabled show a disabled Open action with explanatory tooltip copy.

## Usage Instructions

Admins can open Admin Settings > Custom Pages to enable the feature and create metadata for simple static pages. Metadata references files already deployed under `application/single_app/custom_pages/html`, `css`, `js`, `assets`, and `json`.

Admins can use the table's Open action to launch an enabled static or Python-backed page directly from Admin Settings. The action opens `/custom/<slug>` in a new tab with the admin's current session, so the same route-level authorization outcome applies as it would from navigation or a direct URL.

For CSS, JavaScript, asset, and JSON references, admins add one file at a time using the Add button in the metadata designer. The backend still persists those values as arrays in the `custom_pages` Cosmos container.

When Allowed Roles is blank, any signed-in app user can access the custom page. When Allowed Roles has values, users must have at least one matching role in the current session. `Admin` satisfies `User`, but `Admin` does not satisfy arbitrary custom roles. Unknown role names do not grant access.
Expand All @@ -125,7 +132,7 @@ The Python-backed example is discovered from code, so it should not be created i

## Testing and Validation

Functional coverage was added in `functional_tests/test_custom_pages_wiring.py`. It validates version/configuration wiring, protected route registration, Admin Settings UI wiring, navigation integration, and the trusted HTML rendering boundary without requiring live Cosmos DB connectivity.
Functional coverage was added in `functional_tests/test_custom_pages_wiring.py`. It validates version/configuration wiring, protected route registration, Admin Settings UI wiring, navigation integration, table Open action behavior, and the trusted HTML rendering boundary without requiring live Cosmos DB connectivity. UI contract coverage for the Admin Settings Open action lives in `ui_tests/test_admin_custom_pages_open_action.py`.

Known limitations:

Expand Down
8 changes: 8 additions & 0 deletions docs/explanation/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
* Restore supports configured target Cosmos DB, AI Search, and Enhanced Citation blob targets while preserving secret-safe review and job responses.
* (Ref: Closes #1091, `functions_data_management.py`, `functions_data_management_restore_state.py`, `route_backend_data_management.py`, `admin_settings.html`, `admin_data_management.js`, `DATA_MANAGEMENT_RESTORE.md`)

#### User Interface Enhancements

* **Custom Pages Admin Open Action**
* Added an Open action to the Admin Settings Custom Pages table so administrators can launch enabled static or Python-backed custom pages directly from their metadata row.
* The action opens encoded `/custom/<slug>` URLs in a new tab while preserving existing Custom Pages route authorization, enabled-state checks, access-level rules, role restrictions, and `.html` alias compatibility.
* Disabled or unavailable pages now show a disabled Open action with explanatory tooltip copy instead of silently omitting the action.
* (Ref: Closes #951, PR #1131, `admin_custom_pages.js`, `CUSTOM_PAGES.md`)

### **(v0.250.105)**

#### User Interface Enhancements
Expand Down
28 changes: 18 additions & 10 deletions functional_tests/test_custom_pages_wiring.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# test_custom_pages_wiring.py
"""
Functional test for Custom Pages wiring.
Version: 0.250.025
Version: 0.250.106
Implemented in: 0.242.023
Updated in: 0.250.025
Updated in: 0.250.106

This test ensures that the Custom Pages feature is wired through settings,
navigation, admin metadata management, and fail-closed host routes without
Expand Down Expand Up @@ -40,7 +40,7 @@ def test_custom_pages_configuration():
config = read_text("application/single_app/config.py")
settings = read_text("application/single_app/functions_settings.py")

assert_contains(config, 'VERSION = "0.250.025"', "version bump")
assert_contains(config, 'VERSION = "0.250.106"', "version bump")
assert_contains(config, 'cosmos_custom_pages_container_name = "custom_pages"', "custom pages container name")
assert_contains(config, 'cosmos_custom_pages_container = cosmos_database.create_container_if_not_exists', "custom pages container creation")
assert_contains(settings, "'enable_custom_pages': False", "custom pages disabled default")
Expand All @@ -57,12 +57,12 @@ def test_custom_pages_routes_and_access_controls():
base_template = read_text("application/single_app/templates/base.html")

for route in (
'@app.route("/custom/<slug>", methods=["GET"])',
'@app.route("/custom/<slug>.html", methods=["GET"])',
'@app.route("/custom/assets/<slug>/<folder>/<path:filename>", methods=["GET"])',
'@app.route("/api/custom/<slug>/<path:operation>", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])',
'@app.route("/api/admin/custom-pages", methods=["GET"])',
'@app.route("/api/admin/custom-pages/developer-guide", methods=["GET"])',
'@bp.route("/custom/<slug>", methods=["GET"])',
'@bp.route("/custom/<slug>.html", methods=["GET"])',
'@bp.route("/custom/assets/<slug>/<folder>/<path:filename>", methods=["GET"])',
'@bp.route("/api/custom/<slug>/<path:operation>", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])',
'@bp.route("/api/admin/custom-pages", methods=["GET"])',
'@bp.route("/api/admin/custom-pages/developer-guide", methods=["GET"])',
):
assert_contains(routes, route, f"route declaration {route}")

Expand All @@ -76,7 +76,7 @@ def test_custom_pages_routes_and_access_controls():
assert_contains(routes, "request-access", "request access metadata helper route")
assert_contains(routes, "access_request_button_enabled", "access request button settings update")
assert_contains(routes, "A custom page with this slug already exists.", "duplicate slug create guard")
assert_contains(app, "register_route_custom_pages(app)", "custom page route registration")
assert_contains(app, "register_route_blueprint('custom_pages', register_route_custom_pages, login_required_blueprint)", "custom page route registration")
assert_contains(app, "custom_pages_nav=custom_pages_nav", "custom page navigation context")
assert_contains(base_template, "_custom_pages_drawer.html", "custom pages drawer include")

Expand Down Expand Up @@ -151,6 +151,14 @@ def test_custom_pages_admin_and_navigation_wiring():
assert_contains(admin_js, "setupCustomPageFileListEditors", "custom pages file list editor setup")
assert_contains(admin_js, "addCustomPageFile", "custom pages file list add action")
assert_contains(admin_js, "removeCustomPageFile", "custom pages file list remove action")
assert_contains(admin_js, "createCustomPageOpenAction", "custom page table open action")
assert_contains(admin_js, "getCustomPageUrl", "custom page open URL builder")
assert_contains(admin_js, "`/custom/${encodeURIComponent(slug)}`", "encoded custom page open URL")
assert_contains(admin_js, "openLink.target = \"_blank\"", "custom page open action new tab target")
assert_contains(admin_js, "openLink.rel = \"noopener noreferrer\"", "custom page open action tab isolation")
assert_contains(admin_js, "Route authorization still applies.", "custom page open action authorization tooltip")
assert_contains(admin_js, "Disabled custom pages are not available to open.", "custom page disabled open tooltip")
assert_contains(admin_js, "Enable Custom Pages, save settings, and restart the app before opening custom pages.", "custom pages disabled feature open tooltip")
assert_contains(admin_js, "textContent", "safe DOM text rendering")

drawer_template = read_text("application/single_app/templates/_custom_pages_drawer.html")
Expand Down
38 changes: 38 additions & 0 deletions ui_tests/test_admin_custom_pages_open_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# test_admin_custom_pages_open_action.py
"""
UI contract test for the Admin Settings Custom Pages Open action.
Version: 0.250.106
Implemented in: 0.250.106

This test ensures the Custom Pages table exposes a safe Open action that links
through the existing `/custom/<slug>` route and keeps unavailable pages disabled.
"""

from pathlib import Path

import pytest


REPO_ROOT = Path(__file__).resolve().parents[1]
ADMIN_CUSTOM_PAGES_JS = REPO_ROOT / "application" / "single_app" / "static" / "js" / "admin" / "admin_custom_pages.js"


@pytest.mark.ui
def test_admin_custom_pages_table_open_action_contract():
"""Validate the admin Custom Pages table exposes the expected Open action behavior."""
script = ADMIN_CUSTOM_PAGES_JS.read_text(encoding="utf-8")

required_markers = [
"createCustomPageOpenAction(page)",
"openLink.href = getCustomPageUrl(page)",
"openLink.target = \"_blank\"",
"openLink.rel = \"noopener noreferrer\"",
"`/custom/${encodeURIComponent(slug)}`",
"window.customPagesInitiallyEnabled !== true",
"disabledButton.disabled = true",
"Disabled custom pages are not available to open.",
"Route authorization still applies.",
]

for marker in required_markers:
assert marker in script, f"Missing Custom Pages Open action marker: {marker}"
Loading