Merge pull request #360 from christianlouis/copilot/fix-settings-page-errors

Fix settings page stale values, export-env 400 error, in-process settings reload, and add Back to ENV button
This commit is contained in:
Christian Krakau-Louis
2026-02-23 12:50:20 +01:00
committed by GitHub
4 changed files with 205 additions and 147 deletions
+139 -139
View File
@@ -103,6 +103,145 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser):
)
@router.get("/credentials")
async def list_credentials(request: Request, db: DbSession, admin: AdminUser):
"""
List all sensitive credential settings with their configured/unconfigured status.
Returns a credential audit report indicating which credentials are set and whether
each value originates from the database or an environment variable.
This endpoint is intended to support credential rotation workflows.
Admin only.
"""
try:
db_settings = get_all_settings_from_db(db)
credentials = []
for key, meta in SETTING_METADATA.items():
if not meta.get("sensitive", False):
continue
env_value = getattr(settings, key, None)
in_db = key in db_settings and db_settings[key]
if in_db:
source = "db"
configured = True
elif env_value:
source = "env"
configured = True
else:
source = None
configured = False
credentials.append(
{
"key": key,
"category": meta.get("category", "Other"),
"description": meta.get("description", ""),
"configured": configured,
"source": source,
"restart_required": meta.get("restart_required", False),
}
)
configured_count = sum(1 for c in credentials if c["configured"])
return {
"credentials": credentials,
"total": len(credentials),
"configured_count": configured_count,
"unconfigured_count": len(credentials) - configured_count,
}
except Exception as e:
logger.error(f"Error retrieving credential list: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve credentials",
)
@router.get("/audit-log")
async def list_audit_log(
request: Request,
db: DbSession,
admin: AdminUser,
limit: int = 100,
offset: int = 0,
):
"""
Retrieve the settings audit log (most recent first).
Returns all configuration changes recorded in the audit log.
Sensitive values are masked in the response.
Admin only.
"""
try:
entries = get_audit_log(db, limit=limit, offset=offset)
return {"entries": entries, "limit": limit, "offset": offset}
except Exception as e:
logger.error(f"Error retrieving audit log: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve audit log",
)
@router.get("/export-env")
async def export_env_settings(
request: Request,
db: DbSession,
admin: AdminUser,
source: str = "db",
):
"""
Export current settings as a ``.env`` file.
Query params:
- ``source=db`` (default) – only settings explicitly saved to the database.
- ``source=effective`` – full runtime configuration (DB > ENV > defaults) for
every key defined in SETTING_METADATA.
Returns a downloadable plain-text file suitable for bootstrapping another
installation. All values — including sensitive ones — are included; only
admins can access this endpoint.
"""
from fastapi.responses import Response as FastAPIResponse
from app.utils.settings_service import get_settings_for_export
if source not in ("db", "effective"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="source must be 'db' or 'effective'",
)
try:
export_data = get_settings_for_export(db, source=source)
lines = [
"# DocuElevate configuration export",
f"# Source: {source}",
"# Generated by DocuElevate Settings Export",
"# WARNING: This file contains sensitive values. Handle with care.",
"",
]
for env_key, value in export_data.items():
lines.append(f"{env_key}={value}")
lines.append("") # trailing newline
content = "\n".join(lines)
return FastAPIResponse(
content=content,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="docuelevate-{source}.env"'},
)
except Exception as e:
logger.error(f"Error exporting settings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to export settings",
)
@router.get("/{key}", response_model=SettingResponse)
async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUser):
"""
@@ -220,63 +359,6 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin
)
@router.get("/credentials")
async def list_credentials(request: Request, db: DbSession, admin: AdminUser):
"""
List all sensitive credential settings with their configured/unconfigured status.
Returns a credential audit report indicating which credentials are set and whether
each value originates from the database or an environment variable.
This endpoint is intended to support credential rotation workflows.
Admin only.
"""
try:
db_settings = get_all_settings_from_db(db)
credentials = []
for key, meta in SETTING_METADATA.items():
if not meta.get("sensitive", False):
continue
env_value = getattr(settings, key, None)
in_db = key in db_settings and db_settings[key]
if in_db:
source = "db"
configured = True
elif env_value:
source = "env"
configured = True
else:
source = None
configured = False
credentials.append(
{
"key": key,
"category": meta.get("category", "Other"),
"description": meta.get("description", ""),
"configured": configured,
"source": source,
"restart_required": meta.get("restart_required", False),
}
)
configured_count = sum(1 for c in credentials if c["configured"])
return {
"credentials": credentials,
"total": len(credentials),
"configured_count": configured_count,
"unconfigured_count": len(credentials) - configured_count,
}
except Exception as e:
logger.error(f"Error retrieving credential list: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve credentials",
)
@router.post("/bulk-update")
async def bulk_update_settings(updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser):
"""
@@ -323,32 +405,6 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d
}
@router.get("/audit-log")
async def list_audit_log(
request: Request,
db: DbSession,
admin: AdminUser,
limit: int = 100,
offset: int = 0,
):
"""
Retrieve the settings audit log (most recent first).
Returns all configuration changes recorded in the audit log.
Sensitive values are masked in the response.
Admin only.
"""
try:
entries = get_audit_log(db, limit=limit, offset=offset)
return {"entries": entries, "limit": limit, "offset": offset}
except Exception as e:
logger.error(f"Error retrieving audit log: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve audit log",
)
@router.get("/{key}/history")
async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser):
"""
@@ -416,59 +472,3 @@ async def rollback_setting_to_history(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to roll back setting: {key}",
)
@router.get("/export-env")
async def export_env_settings(
request: Request,
db: DbSession,
admin: AdminUser,
source: str = "db",
):
"""
Export current settings as a ``.env`` file.
Query params:
- ``source=db`` (default) – only settings explicitly saved to the database.
- ``source=effective`` – full runtime configuration (DB > ENV > defaults) for
every key defined in SETTING_METADATA.
Returns a downloadable plain-text file suitable for bootstrapping another
installation. All values — including sensitive ones — are included; only
admins can access this endpoint.
"""
from fastapi.responses import Response as FastAPIResponse
from app.utils.settings_service import get_settings_for_export
if source not in ("db", "effective"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="source must be 'db' or 'effective'",
)
try:
export_data = get_settings_for_export(db, source=source)
lines = [
"# DocuElevate configuration export",
f"# Source: {source}",
"# Generated by DocuElevate Settings Export",
"# WARNING: This file contains sensitive values. Handle with care.",
"",
]
for env_key, value in export_data.items():
lines.append(f"{env_key}={value}")
lines.append("") # trailing newline
content = "\n".join(lines)
return FastAPIResponse(
content=content,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="docuelevate-{source}.env"'},
)
except Exception as e:
logger.error(f"Error exporting settings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to export settings",
)
+16 -1
View File
@@ -38,7 +38,9 @@ _last_seen_version: str = ""
def notify_settings_updated() -> None:
"""
Publish a settings-updated signal by updating the Redis version key.
Publish a settings-updated signal by updating the Redis version key, and
immediately reload the in-process ``settings`` singleton so the API
process serves fresh values without a restart.
Call this after every successful settings write so that all worker
processes know they need to reload their in-memory configuration.
@@ -56,6 +58,19 @@ def notify_settings_updated() -> None:
except Exception as exc:
logger.warning(f"Could not publish settings update to Redis: {exc}")
# Reload the in-process settings singleton immediately so the API node
# returns updated values (e.g. oauth_provider_name on the login page)
# without needing a restart. Workers use the task_prerun signal handler
# instead, so this only affects the API/web process.
try:
from app.config import settings
from app.utils.config_loader import reload_settings_from_db
reload_settings_from_db(settings)
logger.debug("In-process settings reloaded after settings update")
except Exception as exc:
logger.warning(f"Could not reload in-process settings: {exc}")
def register_settings_reload_signal() -> None:
"""
+5 -5
View File
@@ -72,25 +72,25 @@ async def settings_page(request: Request, db: Session = Depends(get_db)):
for category, keys in categories.items():
settings_data[category] = []
for key in keys:
# Get current value from settings (already has precedence applied)
value = getattr(settings, key, None)
# Determine the source of this setting
# Check if it's in the database
# Determine the source of this setting and get the effective value
# Check if it's in the database (DB takes precedence)
if key in db_settings:
source = "database"
source_label = "DB"
source_color = "green"
value = db_settings[key]
# Check if it's from environment variable
elif key.upper() in os.environ or key in os.environ:
source = "environment"
source_label = "ENV"
source_color = "blue"
value = getattr(settings, key, None)
else:
# It's using the default value
source = "default"
source_label = "DEFAULT"
source_color = "gray"
value = getattr(settings, key, None)
# Get metadata
metadata = get_setting_metadata(key)
+45 -2
View File
@@ -185,7 +185,7 @@
{% endif %}
</div>
<!-- Per-setting Save button (visible only when value has changed) -->
<!-- Per-setting Save / Back-to-ENV buttons -->
<div class="ml-4 flex-shrink-0 flex flex-col items-end gap-1 pt-1">
<button
type="button"
@@ -199,6 +199,18 @@
<span x-show="savingKey !== '{{ setting.key }}'"><i class="fas fa-save mr-1"></i>Save</span>
<span x-show="savingKey === '{{ setting.key }}'">Saving…</span>
</button>
<button
type="button"
x-show="isDbOverride['{{ setting.key }}'] && formData['{{ setting.key }}'] === originalData['{{ setting.key }}']"
x-transition
@click="revertSetting('{{ setting.key }}')"
:disabled="revertingKey === '{{ setting.key }}'"
class="px-3 py-1 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-400 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title="Remove DB override and revert to environment variable or default"
>
<span x-show="revertingKey !== '{{ setting.key }}'"><i class="fas fa-undo mr-1"></i>Back to ENV</span>
<span x-show="revertingKey === '{{ setting.key }}'">Reverting…</span>
</button>
</div>
</div>
</div>
@@ -233,9 +245,11 @@ function settingsApp() {
return {
formData: {},
originalData: {},
isDbOverride: {},
showPassword: {},
saving: false,
savingKey: null,
revertingKey: null,
showAlert: false,
alertType: 'success',
alertTitle: '',
@@ -247,6 +261,7 @@ function settingsApp() {
{% for setting in settings_list %}
this.formData['{{ setting.key }}'] = {{ setting.display_value|tojson }};
this.originalData['{{ setting.key }}'] = {{ setting.display_value|tojson }};
this.isDbOverride['{{ setting.key }}'] = {{ (setting.source == 'database')|tojson }};
this.showPassword['{{ setting.key }}'] = false;
{% endfor %}
{% endfor %}
@@ -297,6 +312,7 @@ function settingsApp() {
if (response.ok && result.success) {
this.originalData[key] = value;
this.isDbOverride[key] = true;
let message = `Setting '${key}' saved successfully.`;
if (result.restart_required) {
message += ' Please restart the application for this change to take effect.';
@@ -313,6 +329,30 @@ function settingsApp() {
}
},
async revertSetting(key) {
this.revertingKey = key;
this.hideAlert();
try {
const response = await fetch(`/api/settings/${key}`, {
method: 'DELETE',
});
if (response.ok) {
// Reload the page so the ENV/default value and source badge refresh
window.location.reload();
} else {
const result = await response.json();
this.showErrorAlert('Revert Failed', result.detail || 'Unknown error');
this.revertingKey = null;
}
} catch (error) {
console.error('Error reverting setting:', error);
this.showErrorAlert('Error', 'Failed to revert setting. Please try again.');
this.revertingKey = null;
}
},
async saveSettings() {
this.saving = true;
this.hideAlert();
@@ -345,7 +385,10 @@ function settingsApp() {
const result = await response.json();
if (response.ok && result.success) {
this.originalData = { ...this.formData };
for (const updated of result.updated) {
this.originalData[updated.key] = updated.value;
this.isDbOverride[updated.key] = true;
}
let message = `${result.updated.length} setting(s) updated successfully.`;
if (result.restart_required) {
message += ' Please restart the application for changes to take effect.';