feat(settings): persist storage provider settings to DB, add export endpoint, enrich setup wizard

- dropbox/google_drive/onedrive save-settings: switch to DB as primary,
  .env write as best-effort (no longer fails when .env is absent)
- onedrive/google_drive update-settings: persist changes to DB alongside
  in-memory update; call notify_settings_updated()
- onedrive test_onedrive_token: persist rotated refresh token to DB
- settings_service: add get_settings_for_export() (db / effective modes)
- settings API: add GET /api/settings/export-env (admin-only, downloads .env)
- wizard: enrich settings with current values (DB > ENV > default) and
  value_source badges; pass setup_skipped to template; call
  notify_settings_updated() on save; add /setup/undo-skip route
- setup_wizard.html: pre-populate inputs with current_value; show
  DB/ENV/DEFAULT source badges; skip/undo-skip messaging
- settings.html: replace single Audit Log button with Setup Wizard link,
  Export .env dropdown, and Audit Log button group

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-23 02:41:04 +00:00
parent 0d5c20f685
commit ecc8100e24
8 changed files with 651 additions and 180 deletions
+190 -75
View File
@@ -8,11 +8,15 @@ from datetime import datetime, timedelta
from typing import Annotated, Optional
import requests
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
# Set up logging
logger = logging.getLogger(__name__)
@@ -47,10 +51,15 @@ async def exchange_onedrive_token(
}
# Use shared OAuth helper (handles secure logging and error handling)
token_data = exchange_oauth_token(provider_name="OneDrive", token_url=token_url, payload=payload)
token_data = exchange_oauth_token(
provider_name="OneDrive", token_url=token_url, payload=payload
)
# Return just what's needed by the frontend
return {"refresh_token": token_data["refresh_token"], "expires_in": token_data.get("expires_in", 3600)}
return {
"refresh_token": token_data["refresh_token"],
"expires_in": token_data.get("expires_in", 3600),
}
@router.get("/onedrive/test-token")
@@ -68,7 +77,10 @@ async def test_onedrive_token(request: Request):
or not settings.onedrive_client_secret
):
logger.warning("OneDrive credentials not fully configured")
return {"status": "error", "message": "OneDrive credentials are not fully configured"}
return {
"status": "error",
"message": "OneDrive credentials are not fully configured",
}
# Refresh token to get a new access token and expiration info
tenant_id = settings.onedrive_tenant_id or "common"
@@ -82,27 +94,39 @@ async def test_onedrive_token(request: Request):
"scope": "offline_access Files.ReadWrite",
}
response = requests.post(token_url, data=refresh_data, timeout=settings.http_request_timeout)
response = requests.post(
token_url, data=refresh_data, timeout=settings.http_request_timeout
)
if response.status_code != 200:
logger.error(f"Failed to refresh OneDrive token: {response.text}")
return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True}
return {
"status": "error",
"message": "Refresh token has expired or is invalid",
"needs_reauth": True,
}
token_data = response.json()
access_token = token_data.get("access_token")
expires_in = token_data.get("expires_in", 3600) # Default to 1 hour if not specified
expires_in = token_data.get(
"expires_in", 3600
) # Default to 1 hour if not specified
# Check if we got a new refresh token (Microsoft sometimes issues a new one)
new_refresh_token = token_data.get("refresh_token")
if new_refresh_token and new_refresh_token != settings.onedrive_refresh_token:
logger.info("Received new refresh token from Microsoft - will update configuration")
logger.info(
"Received new refresh token from Microsoft - will update configuration"
)
# Update refresh token in memory
settings.onedrive_refresh_token = new_refresh_token
# Also try to update .env file if it exists
try:
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
env_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env"
)
if os.path.exists(env_path):
with open(env_path, "r") as f:
env_lines = f.readlines()
@@ -112,13 +136,17 @@ async def test_onedrive_token(request: Request):
for line in env_lines:
if line.startswith("ONEDRIVE_REFRESH_TOKEN="):
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
updated_lines.append(
f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n"
)
updated = True
else:
updated_lines.append(line)
if not updated:
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
updated_lines.append(
f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n"
)
with open(env_path, "w") as f:
f.writelines(updated_lines)
@@ -128,14 +156,38 @@ async def test_onedrive_token(request: Request):
except Exception as e:
logger.warning(f"Failed to update refresh token in .env file: {e}")
# Persist the rotated refresh token to the database
try:
from app.database import SessionLocal
_db = SessionLocal()
try:
save_setting_to_db(
_db,
"onedrive_refresh_token",
new_refresh_token,
changed_by="onedrive_token_rotation",
)
notify_settings_updated()
finally:
_db.close()
except Exception as _e:
logger.warning(
f"Failed to persist rotated OneDrive refresh token to database: {_e}"
)
# Test the access token by getting user information
user_info_url = "https://graph.microsoft.com/v1.0/me"
headers = {"Authorization": f"Bearer {access_token}"}
user_response = requests.get(user_info_url, headers=headers, timeout=settings.http_request_timeout)
user_response = requests.get(
user_info_url, headers=headers, timeout=settings.http_request_timeout
)
if user_response.status_code != 200:
logger.error(f"OneDrive token test failed: {user_response.status_code} {user_response.text}")
logger.error(
f"OneDrive token test failed: {user_response.status_code} {user_response.text}"
)
return {
"status": "error",
"message": f"Token validation failed with status {user_response.status_code}: {user_response.text}",
@@ -203,65 +255,72 @@ async def save_onedrive_settings(
client_secret: Annotated[Optional[str], Form()] = None,
tenant_id: Annotated[str, Form()] = "common",
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Save OneDrive settings to the .env file
Saves to database (primary) and .env file (best-effort).
"""
try:
# Get the path to the .env file
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username")
or user.get("username")
or user.get("email")
or user.get("id")
or "wizard"
)
if not os.path.exists(env_path):
logger.error(f".env file not found at {env_path}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update"
# Best-effort .env file write
try:
env_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env"
)
if not os.path.exists(env_path):
logger.warning(
f".env file not found at {env_path}, skipping file write"
)
else:
logger.info(f"Updating OneDrive settings in {env_path}")
logger.info(f"Updating OneDrive settings in {env_path}")
with open(env_path, "r") as f:
env_lines = f.readlines()
# Read the current .env file
with open(env_path, "r") as f:
env_lines = f.readlines()
onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token}
if client_id:
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
if client_secret:
onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret
if tenant_id:
onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id
if folder_path:
onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path
# Define settings to update
onedrive_settings = {
"ONEDRIVE_REFRESH_TOKEN": refresh_token,
}
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in onedrive_settings.items():
if stripped_line.startswith(
f"{key}="
) or stripped_line.startswith(f"# {key}="):
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
# Only update these if provided
if client_id:
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
if client_secret:
onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret
if tenant_id:
onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id
if folder_path:
onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path
for key, value in onedrive_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Process each line and update or add settings
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in onedrive_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
# Uncomment if commented out - check the original stripped line
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
# Add any settings that weren't updated (they weren't in the file)
for key, value in onedrive_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Write the updated .env file
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
logger.info("Successfully updated OneDrive settings in .env file")
except Exception as env_err:
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
# Update the settings in memory
if refresh_token:
@@ -275,16 +334,38 @@ async def save_onedrive_settings(
if folder_path:
settings.onedrive_folder_path = folder_path
logger.info("Successfully updated OneDrive settings")
# Persist to database (primary)
if refresh_token:
save_setting_to_db(
db, "onedrive_refresh_token", refresh_token, changed_by=changed_by
)
if client_id:
save_setting_to_db(
db, "onedrive_client_id", client_id, changed_by=changed_by
)
if client_secret:
save_setting_to_db(
db, "onedrive_client_secret", client_secret, changed_by=changed_by
)
if tenant_id:
save_setting_to_db(
db, "onedrive_tenant_id", tenant_id, changed_by=changed_by
)
if folder_path:
save_setting_to_db(
db, "onedrive_folder_path", folder_path, changed_by=changed_by
)
notify_settings_updated()
logger.info("Successfully saved OneDrive settings")
return {"status": "success", "message": "OneDrive settings have been saved"}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Unexpected error saving OneDrive settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save OneDrive settings: {str(e)}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save OneDrive settings: {str(e)}",
)
@@ -297,33 +378,60 @@ async def update_onedrive_settings(
client_secret: Annotated[Optional[str], Form()] = None,
tenant_id: Annotated[str, Form()] = "common",
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Update OneDrive settings in memory (without modifying .env file)
Update OneDrive settings in memory and persist to database
"""
try:
logger.info("Updating OneDrive settings in memory")
logger.info("Updating OneDrive settings in memory and database")
# Update settings in memory
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username")
or user.get("username")
or user.get("email")
or user.get("id")
or "wizard"
)
# Update settings in memory and persist to database
if refresh_token:
settings.onedrive_refresh_token = refresh_token
logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory")
save_setting_to_db(
db, "onedrive_refresh_token", refresh_token, changed_by=changed_by
)
logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory and database")
if client_id:
settings.onedrive_client_id = client_id
logger.info("Updated ONEDRIVE_CLIENT_ID in memory")
save_setting_to_db(
db, "onedrive_client_id", client_id, changed_by=changed_by
)
logger.info("Updated ONEDRIVE_CLIENT_ID in memory and database")
if client_secret:
settings.onedrive_client_secret = client_secret
logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory")
save_setting_to_db(
db, "onedrive_client_secret", client_secret, changed_by=changed_by
)
logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory and database")
if tenant_id:
settings.onedrive_tenant_id = tenant_id
logger.info("Updated ONEDRIVE_TENANT_ID in memory")
save_setting_to_db(
db, "onedrive_tenant_id", tenant_id, changed_by=changed_by
)
logger.info("Updated ONEDRIVE_TENANT_ID in memory and database")
if folder_path:
settings.onedrive_folder_path = folder_path
logger.info("Updated ONEDRIVE_FOLDER_PATH in memory")
save_setting_to_db(
db, "onedrive_folder_path", folder_path, changed_by=changed_by
)
logger.info("Updated ONEDRIVE_FOLDER_PATH in memory and database")
notify_settings_updated()
# Test the token to make sure it works
try:
@@ -333,14 +441,21 @@ async def update_onedrive_settings(
logger.info("Successfully tested OneDrive token")
except Exception as e:
logger.error(f"Token test failed after updating settings: {str(e)}")
return {"status": "warning", "message": "Settings updated but token test failed: " + str(e)}
return {
"status": "warning",
"message": "Settings updated but token test failed: " + str(e),
}
return {"status": "success", "message": "OneDrive settings have been updated in memory"}
return {
"status": "success",
"message": "OneDrive settings have been updated in memory and database",
}
except Exception as e:
logger.exception(f"Unexpected error updating OneDrive settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update OneDrive settings: {str(e)}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update OneDrive settings: {str(e)}",
)