fix(admin): fix greyed-out QR login toggle on admin connections page

- Add `qr_login_enabled` boolean config field (default True) to app/config.py
- Add `qr_login_enabled` entry to settings metadata in app/utils/settings_service.py
- Fix app/views/settings.py to use `qr_login_enabled` directly instead of
  deriving enablement status from qr_login_challenge_ttl_seconds (integer TTL)
- Fix admin_connections.html: remove hardcoded `disabled` attribute from the
  Mobile Phone Upload toggle and wire up onchange handler so toggling actually
  persists the setting via toggleSetting('qr_login_enabled', this.checked)
- Gate all three QR auth API endpoints on settings.qr_login_enabled so the
  feature is actually disabled when the toggle is turned off

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/8421cdb2-d92d-4b83-9cda-c44983d35173
This commit is contained in:
copilot-swe-agent[bot]
2026-03-22 16:06:55 +00:00
parent 61dee5ba52
commit d4cc44a72f
5 changed files with 31 additions and 2 deletions
+16
View File
@@ -31,6 +31,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import require_login from app.auth import require_login
from app.config import settings
from app.database import get_db from app.database import get_db
from app.middleware.audit_log import get_client_ip from app.middleware.audit_log import get_client_ip
from app.utils.session_manager import ( from app.utils.session_manager import (
@@ -151,6 +152,11 @@ async def create_challenge(
displayed to the user. The mobile app scans this QR code and displayed to the user. The mobile app scans this QR code and
calls the ``/claim`` endpoint. calls the ``/claim`` endpoint.
""" """
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
ip = get_client_ip(request) ip = get_client_ip(request)
challenge = create_qr_challenge(db, owner_id, ip_address=ip) challenge = create_qr_challenge(db, owner_id, ip_address=ip)
@@ -187,6 +193,11 @@ async def poll_challenge_status(
The web UI calls this endpoint every few seconds to check if the The web UI calls this endpoint every few seconds to check if the
mobile app has scanned the QR code and claimed the challenge. mobile app has scanned the QR code and claimed the challenge.
""" """
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
result = get_challenge_status(db, challenge_id, owner_id) result = get_challenge_status(db, challenge_id, owner_id)
if not result: if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
@@ -206,6 +217,11 @@ async def claim_challenge(
serves as proof that the user authorized this login from their web serves as proof that the user authorized this login from their web
session. session.
""" """
if not settings.qr_login_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
)
ip = get_client_ip(request) ip = get_client_ip(request)
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip) result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
+4
View File
@@ -244,6 +244,10 @@ class Settings(BaseSettings):
"Useful for admin-configured non-standard durations." "Useful for admin-configured non-standard durations."
), ),
) )
qr_login_enabled: bool = Field(
default=True,
description="Enable QR code-based login for mobile device authentication (default: True).",
)
qr_login_challenge_ttl_seconds: int = Field( qr_login_challenge_ttl_seconds: int = Field(
default=120, default=120,
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).", description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
+8
View File
@@ -206,6 +206,14 @@ SETTING_METADATA = {
"required": False, "required": False,
"restart_required": True, "restart_required": True,
}, },
"qr_login_enabled": {
"category": "Authentication",
"description": "Enable QR code-based login for mobile device authentication.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"qr_login_challenge_ttl_seconds": { "qr_login_challenge_ttl_seconds": {
"category": "Authentication", "category": "Authentication",
"description": "Time-to-live in seconds for QR login challenges (default 120).", "description": "Time-to-live in seconds for QR login challenges (default 120).",
+1 -1
View File
@@ -464,7 +464,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
# Feature toggles # Feature toggles
sso_auto_login = _is_truthy(_get_effective("sso_auto_login")) sso_auto_login = _is_truthy(_get_effective("sso_auto_login"))
qr_login_enabled = _is_truthy(_get_effective("qr_login_challenge_ttl_seconds")) qr_login_enabled = _is_truthy(_get_effective("qr_login_enabled"))
frontend_url_configured = bool(_get_effective("public_base_url")) frontend_url_configured = bool(_get_effective("public_base_url"))
return templates.TemplateResponse( return templates.TemplateResponse(
+2 -1
View File
@@ -46,7 +46,8 @@
<div> <div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="qr-upload-toggle" class="sr-only peer" <input type="checkbox" id="qr-upload-toggle" class="sr-only peer"
{% if qr_login_enabled %}checked{% endif %} disabled> {% if qr_login_enabled %}checked{% endif %}
onchange="toggleSetting('qr_login_enabled', this.checked)">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600" style="min-width:44px; min-height:24px;"></div> <div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600" style="min-width:44px; min-height:24px;"></div>
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span> <span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span>
</label> </label>