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:
+139
-139
@@ -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)
|
@router.get("/{key}", response_model=SettingResponse)
|
||||||
async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUser):
|
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")
|
@router.post("/bulk-update")
|
||||||
async def bulk_update_settings(updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser):
|
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")
|
@router.get("/{key}/history")
|
||||||
async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser):
|
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,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Failed to roll back setting: {key}",
|
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",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ _last_seen_version: str = ""
|
|||||||
|
|
||||||
def notify_settings_updated() -> None:
|
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
|
Call this after every successful settings write so that all worker
|
||||||
processes know they need to reload their in-memory configuration.
|
processes know they need to reload their in-memory configuration.
|
||||||
@@ -56,6 +58,19 @@ def notify_settings_updated() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"Could not publish settings update to Redis: {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:
|
def register_settings_reload_signal() -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -72,25 +72,25 @@ async def settings_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
for category, keys in categories.items():
|
for category, keys in categories.items():
|
||||||
settings_data[category] = []
|
settings_data[category] = []
|
||||||
for key in keys:
|
for key in keys:
|
||||||
# Get current value from settings (already has precedence applied)
|
# Determine the source of this setting and get the effective value
|
||||||
value = getattr(settings, key, None)
|
# Check if it's in the database (DB takes precedence)
|
||||||
|
|
||||||
# Determine the source of this setting
|
|
||||||
# Check if it's in the database
|
|
||||||
if key in db_settings:
|
if key in db_settings:
|
||||||
source = "database"
|
source = "database"
|
||||||
source_label = "DB"
|
source_label = "DB"
|
||||||
source_color = "green"
|
source_color = "green"
|
||||||
|
value = db_settings[key]
|
||||||
# Check if it's from environment variable
|
# Check if it's from environment variable
|
||||||
elif key.upper() in os.environ or key in os.environ:
|
elif key.upper() in os.environ or key in os.environ:
|
||||||
source = "environment"
|
source = "environment"
|
||||||
source_label = "ENV"
|
source_label = "ENV"
|
||||||
source_color = "blue"
|
source_color = "blue"
|
||||||
|
value = getattr(settings, key, None)
|
||||||
else:
|
else:
|
||||||
# It's using the default value
|
# It's using the default value
|
||||||
source = "default"
|
source = "default"
|
||||||
source_label = "DEFAULT"
|
source_label = "DEFAULT"
|
||||||
source_color = "gray"
|
source_color = "gray"
|
||||||
|
value = getattr(settings, key, None)
|
||||||
|
|
||||||
# Get metadata
|
# Get metadata
|
||||||
metadata = get_setting_metadata(key)
|
metadata = get_setting_metadata(key)
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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">
|
<div class="ml-4 flex-shrink-0 flex flex-col items-end gap-1 pt-1">
|
||||||
<button
|
<button
|
||||||
type="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 }}'"><i class="fas fa-save mr-1"></i>Save</span>
|
||||||
<span x-show="savingKey === '{{ setting.key }}'">Saving…</span>
|
<span x-show="savingKey === '{{ setting.key }}'">Saving…</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,9 +245,11 @@ function settingsApp() {
|
|||||||
return {
|
return {
|
||||||
formData: {},
|
formData: {},
|
||||||
originalData: {},
|
originalData: {},
|
||||||
|
isDbOverride: {},
|
||||||
showPassword: {},
|
showPassword: {},
|
||||||
saving: false,
|
saving: false,
|
||||||
savingKey: null,
|
savingKey: null,
|
||||||
|
revertingKey: null,
|
||||||
showAlert: false,
|
showAlert: false,
|
||||||
alertType: 'success',
|
alertType: 'success',
|
||||||
alertTitle: '',
|
alertTitle: '',
|
||||||
@@ -247,6 +261,7 @@ function settingsApp() {
|
|||||||
{% for setting in settings_list %}
|
{% for setting in settings_list %}
|
||||||
this.formData['{{ setting.key }}'] = {{ setting.display_value|tojson }};
|
this.formData['{{ setting.key }}'] = {{ setting.display_value|tojson }};
|
||||||
this.originalData['{{ 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;
|
this.showPassword['{{ setting.key }}'] = false;
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -297,6 +312,7 @@ function settingsApp() {
|
|||||||
|
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
this.originalData[key] = value;
|
this.originalData[key] = value;
|
||||||
|
this.isDbOverride[key] = true;
|
||||||
let message = `Setting '${key}' saved successfully.`;
|
let message = `Setting '${key}' saved successfully.`;
|
||||||
if (result.restart_required) {
|
if (result.restart_required) {
|
||||||
message += ' Please restart the application for this change to take effect.';
|
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() {
|
async saveSettings() {
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.hideAlert();
|
this.hideAlert();
|
||||||
@@ -345,7 +385,10 @@ function settingsApp() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
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.`;
|
let message = `${result.updated.length} setting(s) updated successfully.`;
|
||||||
if (result.restart_required) {
|
if (result.restart_required) {
|
||||||
message += ' Please restart the application for changes to take effect.';
|
message += ' Please restart the application for changes to take effect.';
|
||||||
|
|||||||
Reference in New Issue
Block a user