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:
+126
-71
@@ -49,7 +49,9 @@ async def exchange_dropbox_token(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Use shared OAuth helper (handles secure logging and error handling)
|
# Use shared OAuth helper (handles secure logging and error handling)
|
||||||
token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload)
|
token_data = exchange_oauth_token(
|
||||||
|
provider_name="Dropbox", token_url=token_url, payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
# Return just what's needed by the frontend
|
# Return just what's needed by the frontend
|
||||||
return {
|
return {
|
||||||
@@ -77,13 +79,19 @@ async def update_dropbox_settings(
|
|||||||
|
|
||||||
user = request.session.get("user", {}) if hasattr(request, "session") else {}
|
user = request.session.get("user", {}) if hasattr(request, "session") else {}
|
||||||
changed_by = (
|
changed_by = (
|
||||||
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard"
|
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
|
# Update settings in memory and persist to database
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
settings.dropbox_refresh_token = refresh_token
|
settings.dropbox_refresh_token = refresh_token
|
||||||
save_setting_to_db(db, "dropbox_refresh_token", refresh_token, changed_by=changed_by)
|
save_setting_to_db(
|
||||||
|
db, "dropbox_refresh_token", refresh_token, changed_by=changed_by
|
||||||
|
)
|
||||||
logger.info("Updated DROPBOX_REFRESH_TOKEN in memory and database")
|
logger.info("Updated DROPBOX_REFRESH_TOKEN in memory and database")
|
||||||
|
|
||||||
if app_key:
|
if app_key:
|
||||||
@@ -93,7 +101,9 @@ async def update_dropbox_settings(
|
|||||||
|
|
||||||
if app_secret:
|
if app_secret:
|
||||||
settings.dropbox_app_secret = app_secret
|
settings.dropbox_app_secret = app_secret
|
||||||
save_setting_to_db(db, "dropbox_app_secret", app_secret, changed_by=changed_by)
|
save_setting_to_db(
|
||||||
|
db, "dropbox_app_secret", app_secret, changed_by=changed_by
|
||||||
|
)
|
||||||
logger.info("Updated DROPBOX_APP_SECRET in memory and database")
|
logger.info("Updated DROPBOX_APP_SECRET in memory and database")
|
||||||
|
|
||||||
if folder_path:
|
if folder_path:
|
||||||
@@ -103,12 +113,16 @@ async def update_dropbox_settings(
|
|||||||
|
|
||||||
notify_settings_updated()
|
notify_settings_updated()
|
||||||
|
|
||||||
return {"status": "success", "message": "Dropbox settings have been updated in memory and saved to database"}
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Dropbox settings have been updated in memory and saved to database",
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Unexpected error updating Dropbox settings: {str(e)}")
|
logger.exception(f"Unexpected error updating Dropbox settings: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update Dropbox settings: {str(e)}"
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to update Dropbox settings: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,9 +135,16 @@ async def test_dropbox_token(request: Request):
|
|||||||
try:
|
try:
|
||||||
logger.info("Testing Dropbox token validity")
|
logger.info("Testing Dropbox token validity")
|
||||||
|
|
||||||
if not settings.dropbox_refresh_token or not settings.dropbox_app_key or not settings.dropbox_app_secret:
|
if (
|
||||||
|
not settings.dropbox_refresh_token
|
||||||
|
or not settings.dropbox_app_key
|
||||||
|
or not settings.dropbox_app_secret
|
||||||
|
):
|
||||||
logger.warning("Dropbox credentials not fully configured")
|
logger.warning("Dropbox credentials not fully configured")
|
||||||
return {"status": "error", "message": "Dropbox credentials are not fully configured"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Dropbox credentials are not fully configured",
|
||||||
|
}
|
||||||
|
|
||||||
# Check token validity by getting current account info
|
# Check token validity by getting current account info
|
||||||
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
|
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
|
||||||
@@ -146,11 +167,19 @@ async def test_dropbox_token(request: Request):
|
|||||||
"client_secret": settings.dropbox_app_secret,
|
"client_secret": settings.dropbox_app_secret,
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh_response = requests.post(refresh_url, data=refresh_data, timeout=settings.http_request_timeout)
|
refresh_response = requests.post(
|
||||||
|
refresh_url, data=refresh_data, timeout=settings.http_request_timeout
|
||||||
|
)
|
||||||
|
|
||||||
if refresh_response.status_code != 200:
|
if refresh_response.status_code != 200:
|
||||||
logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}")
|
logger.error(
|
||||||
return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True}
|
f"Failed to refresh Dropbox token: {refresh_response.text}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Refresh token has expired or is invalid",
|
||||||
|
"needs_reauth": True,
|
||||||
|
}
|
||||||
|
|
||||||
token_info = refresh_response.json()
|
token_info = refresh_response.json()
|
||||||
access_token = token_info.get("access_token")
|
access_token = token_info.get("access_token")
|
||||||
@@ -164,7 +193,9 @@ async def test_dropbox_token(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.error(f"Dropbox token test failed: {response.status_code} {response.text}")
|
logger.error(
|
||||||
|
f"Dropbox token test failed: {response.status_code} {response.text}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Token validation failed with status {response.status_code}: {response.text}",
|
"message": f"Token validation failed with status {response.status_code}: {response.text}",
|
||||||
@@ -176,7 +207,10 @@ async def test_dropbox_token(request: Request):
|
|||||||
account_name = account_info.get("name", {}).get("display_name", "Unknown user")
|
account_name = account_info.get("name", {}).get("display_name", "Unknown user")
|
||||||
|
|
||||||
# Dropbox refresh tokens don't expire, but we should note that in our response
|
# Dropbox refresh tokens don't expire, but we should note that in our response
|
||||||
token_info = {"expires_in_human": "Never expires (perpetual token)", "is_perpetual": True}
|
token_info = {
|
||||||
|
"expires_in_human": "Never expires (perpetual token)",
|
||||||
|
"is_perpetual": True,
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(f"Successfully connected to Dropbox as {account_email}")
|
logger.info(f"Successfully connected to Dropbox as {account_email}")
|
||||||
|
|
||||||
@@ -201,65 +235,22 @@ async def save_dropbox_settings(
|
|||||||
app_key: Annotated[Optional[str], Form()] = None,
|
app_key: Annotated[Optional[str], Form()] = None,
|
||||||
app_secret: Annotated[Optional[str], Form()] = None,
|
app_secret: Annotated[Optional[str], Form()] = None,
|
||||||
folder_path: Annotated[Optional[str], Form()] = None,
|
folder_path: Annotated[Optional[str], Form()] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Save Dropbox settings to the .env file
|
Save Dropbox settings to database (primary) and .env file (best-effort).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the path to the .env file
|
user = request.session.get("user", {}) if hasattr(request, "session") else {}
|
||||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
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):
|
# Update settings in memory
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Updating Dropbox settings in {env_path}")
|
|
||||||
|
|
||||||
# Read the current .env file
|
|
||||||
with open(env_path, "r") as f:
|
|
||||||
env_lines = f.readlines()
|
|
||||||
|
|
||||||
# Define settings to update
|
|
||||||
dropbox_settings = {
|
|
||||||
"DROPBOX_REFRESH_TOKEN": refresh_token,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Only update these if provided
|
|
||||||
if app_key:
|
|
||||||
dropbox_settings["DROPBOX_APP_KEY"] = app_key
|
|
||||||
if app_secret:
|
|
||||||
dropbox_settings["DROPBOX_APP_SECRET"] = app_secret
|
|
||||||
if folder_path:
|
|
||||||
dropbox_settings["DROPBOX_FOLDER"] = folder_path
|
|
||||||
|
|
||||||
# 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 dropbox_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)
|
|
||||||
|
|
||||||
# Add any settings that weren't updated (they weren't in the file)
|
|
||||||
for key, value in dropbox_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")
|
|
||||||
|
|
||||||
# Update the settings in memory
|
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
settings.dropbox_refresh_token = refresh_token
|
settings.dropbox_refresh_token = refresh_token
|
||||||
if app_key:
|
if app_key:
|
||||||
@@ -269,14 +260,78 @@ async def save_dropbox_settings(
|
|||||||
if folder_path:
|
if folder_path:
|
||||||
settings.dropbox_folder = folder_path
|
settings.dropbox_folder = folder_path
|
||||||
|
|
||||||
logger.info("Successfully updated Dropbox settings")
|
# Persist to database (primary storage)
|
||||||
|
if refresh_token:
|
||||||
|
save_setting_to_db(
|
||||||
|
db, "dropbox_refresh_token", refresh_token, changed_by=changed_by
|
||||||
|
)
|
||||||
|
if app_key:
|
||||||
|
save_setting_to_db(db, "dropbox_app_key", app_key, changed_by=changed_by)
|
||||||
|
if app_secret:
|
||||||
|
save_setting_to_db(
|
||||||
|
db, "dropbox_app_secret", app_secret, changed_by=changed_by
|
||||||
|
)
|
||||||
|
if folder_path:
|
||||||
|
save_setting_to_db(db, "dropbox_folder", folder_path, changed_by=changed_by)
|
||||||
|
|
||||||
|
# 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 Dropbox settings in {env_path}")
|
||||||
|
|
||||||
|
with open(env_path, "r") as f:
|
||||||
|
env_lines = f.readlines()
|
||||||
|
|
||||||
|
dropbox_settings = {"DROPBOX_REFRESH_TOKEN": refresh_token}
|
||||||
|
if app_key:
|
||||||
|
dropbox_settings["DROPBOX_APP_KEY"] = app_key
|
||||||
|
if app_secret:
|
||||||
|
dropbox_settings["DROPBOX_APP_SECRET"] = app_secret
|
||||||
|
if folder_path:
|
||||||
|
dropbox_settings["DROPBOX_FOLDER"] = folder_path
|
||||||
|
|
||||||
|
updated = set()
|
||||||
|
new_env_lines = []
|
||||||
|
for line in env_lines:
|
||||||
|
stripped_line = line.rstrip()
|
||||||
|
is_updated = False
|
||||||
|
for key, value in dropbox_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)
|
||||||
|
|
||||||
|
for key, value in dropbox_settings.items():
|
||||||
|
if key not in updated:
|
||||||
|
new_env_lines.append(f"{key}={value}")
|
||||||
|
|
||||||
|
with open(env_path, "w") as f:
|
||||||
|
f.write("\n".join(new_env_lines) + "\n")
|
||||||
|
|
||||||
|
logger.info("Successfully updated Dropbox settings in .env file")
|
||||||
|
except Exception as env_err:
|
||||||
|
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
|
||||||
|
|
||||||
|
notify_settings_updated()
|
||||||
|
|
||||||
|
logger.info("Successfully saved Dropbox settings")
|
||||||
return {"status": "success", "message": "Dropbox settings have been saved"}
|
return {"status": "success", "message": "Dropbox settings have been saved"}
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Unexpected error saving Dropbox settings: {str(e)}")
|
logger.exception(f"Unexpected error saving Dropbox settings: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Dropbox settings: {str(e)}"
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to save Dropbox settings: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|||||||
+125
-24
@@ -7,11 +7,15 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
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.auth import require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
from app.utils.oauth_helper import exchange_oauth_token
|
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
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -45,7 +49,9 @@ async def exchange_google_drive_token(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Use shared OAuth helper (handles secure logging and error handling)
|
# Use shared OAuth helper (handles secure logging and error handling)
|
||||||
token_data = exchange_oauth_token(provider_name="Google Drive", token_url=token_url, payload=payload)
|
token_data = exchange_oauth_token(
|
||||||
|
provider_name="Google Drive", token_url=token_url, payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
# Return just what's needed by the frontend
|
# Return just what's needed by the frontend
|
||||||
return {
|
return {
|
||||||
@@ -64,38 +70,73 @@ async def update_google_drive_settings(
|
|||||||
client_secret: Annotated[Optional[str], Form()] = None,
|
client_secret: Annotated[Optional[str], Form()] = None,
|
||||||
folder_id: Annotated[Optional[str], Form()] = None,
|
folder_id: Annotated[Optional[str], Form()] = None,
|
||||||
use_oauth: Annotated[str, Form()] = "true",
|
use_oauth: Annotated[str, Form()] = "true",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update Google Drive settings in memory
|
Update Google Drive settings in memory and persist to database
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("Updating Google Drive settings in memory")
|
logger.info("Updating Google Drive settings in memory and database")
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
# Convert use_oauth string to boolean
|
# Convert use_oauth string to boolean
|
||||||
use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t")
|
use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t")
|
||||||
|
|
||||||
# Update settings in memory
|
# Update settings in memory and persist to database
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
settings.google_drive_refresh_token = refresh_token
|
settings.google_drive_refresh_token = refresh_token
|
||||||
logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory")
|
save_setting_to_db(
|
||||||
|
db, "google_drive_refresh_token", refresh_token, changed_by=changed_by
|
||||||
|
)
|
||||||
|
logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory and database")
|
||||||
|
|
||||||
if client_id:
|
if client_id:
|
||||||
settings.google_drive_client_id = client_id
|
settings.google_drive_client_id = client_id
|
||||||
logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory")
|
save_setting_to_db(
|
||||||
|
db, "google_drive_client_id", client_id, changed_by=changed_by
|
||||||
|
)
|
||||||
|
logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory and database")
|
||||||
|
|
||||||
if client_secret:
|
if client_secret:
|
||||||
settings.google_drive_client_secret = client_secret
|
settings.google_drive_client_secret = client_secret
|
||||||
logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory")
|
save_setting_to_db(
|
||||||
|
db, "google_drive_client_secret", client_secret, changed_by=changed_by
|
||||||
|
)
|
||||||
|
logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory and database")
|
||||||
|
|
||||||
if folder_id:
|
if folder_id:
|
||||||
settings.google_drive_folder_id = folder_id
|
settings.google_drive_folder_id = folder_id
|
||||||
logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory")
|
save_setting_to_db(
|
||||||
|
db, "google_drive_folder_id", folder_id, changed_by=changed_by
|
||||||
|
)
|
||||||
|
logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory and database")
|
||||||
|
|
||||||
# Set the OAuth flag
|
# Set the OAuth flag
|
||||||
settings.google_drive_use_oauth = use_oauth_bool
|
settings.google_drive_use_oauth = use_oauth_bool
|
||||||
logger.info(f"Updated GOOGLE_DRIVE_USE_OAUTH in memory to {use_oauth_bool}")
|
save_setting_to_db(
|
||||||
|
db,
|
||||||
|
"google_drive_use_oauth",
|
||||||
|
str(use_oauth_bool).lower(),
|
||||||
|
changed_by=changed_by,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Updated GOOGLE_DRIVE_USE_OAUTH in memory and database to {use_oauth_bool}"
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "success", "message": "Google Drive settings have been updated in memory"}
|
notify_settings_updated()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Google Drive settings have been updated in memory and database",
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Unexpected error updating Google Drive settings: {str(e)}")
|
logger.exception(f"Unexpected error updating Google Drive settings: {str(e)}")
|
||||||
@@ -113,7 +154,8 @@ async def test_google_drive_token(request: Request):
|
|||||||
Tests both OAuth and service account approaches based on configuration.
|
Tests both OAuth and service account approaches based on configuration.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from app.tasks.upload_to_google_drive import get_drive_service_oauth, get_google_drive_service
|
from app.tasks.upload_to_google_drive import (get_drive_service_oauth,
|
||||||
|
get_google_drive_service)
|
||||||
|
|
||||||
logger.info("Testing Google Drive token validity")
|
logger.info("Testing Google Drive token validity")
|
||||||
|
|
||||||
@@ -125,7 +167,10 @@ async def test_google_drive_token(request: Request):
|
|||||||
and settings.google_drive_refresh_token
|
and settings.google_drive_refresh_token
|
||||||
):
|
):
|
||||||
logger.warning("Google Drive OAuth credentials not fully configured")
|
logger.warning("Google Drive OAuth credentials not fully configured")
|
||||||
return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Google Drive OAuth credentials are not fully configured",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Test OAuth connection
|
# Test OAuth connection
|
||||||
@@ -187,8 +232,13 @@ async def test_google_drive_token(request: Request):
|
|||||||
else:
|
else:
|
||||||
# Test service account connection
|
# Test service account connection
|
||||||
if not settings.google_drive_credentials_json:
|
if not settings.google_drive_credentials_json:
|
||||||
logger.warning("Google Drive service account credentials not configured")
|
logger.warning(
|
||||||
return {"status": "error", "message": "Google Drive service account credentials are not configured"}
|
"Google Drive service account credentials not configured"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Google Drive service account credentials are not configured",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service = get_google_drive_service()
|
service = get_google_drive_service()
|
||||||
@@ -203,7 +253,9 @@ async def test_google_drive_token(request: Request):
|
|||||||
else:
|
else:
|
||||||
user_display = user_email
|
user_display = user_email
|
||||||
|
|
||||||
logger.info(f"Successfully connected to Google Drive using service account as {user_display}")
|
logger.info(
|
||||||
|
f"Successfully connected to Google Drive using service account as {user_display}"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -214,7 +266,10 @@ async def test_google_drive_token(request: Request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
logger.error(f"Google Drive service account test failed: {error_msg}")
|
logger.error(f"Google Drive service account test failed: {error_msg}")
|
||||||
return {"status": "error", "message": f"Service account validation failed: {error_msg}"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Service account validation failed: {error_msg}",
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Unexpected error testing Google Drive token")
|
logger.exception("Unexpected error testing Google Drive token")
|
||||||
@@ -246,7 +301,10 @@ async def get_google_drive_token_info(request: Request):
|
|||||||
and settings.google_drive_refresh_token
|
and settings.google_drive_refresh_token
|
||||||
):
|
):
|
||||||
logger.warning("Google Drive OAuth credentials not fully configured")
|
logger.warning("Google Drive OAuth credentials not fully configured")
|
||||||
return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"}
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Google Drive OAuth credentials are not fully configured",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get credentials and access token
|
# Get credentials and access token
|
||||||
@@ -333,17 +391,29 @@ async def save_dropbox_settings(
|
|||||||
client_secret: Annotated[Optional[str], Form()] = None,
|
client_secret: Annotated[Optional[str], Form()] = None,
|
||||||
folder_id: Annotated[Optional[str], Form()] = None,
|
folder_id: Annotated[Optional[str], Form()] = None,
|
||||||
use_oauth: Annotated[str, Form()] = "true",
|
use_oauth: Annotated[str, Form()] = "true",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Save Google Drive settings to the .env file
|
Save Google Drive settings to the .env file (best-effort) and persist to database.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the path to the .env file
|
# Get the path to the .env file
|
||||||
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"
|
||||||
|
)
|
||||||
|
|
||||||
# Convert use_oauth string to boolean
|
# Convert use_oauth string to boolean
|
||||||
use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t")
|
use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t")
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
# Define settings to update
|
# Define settings to update
|
||||||
drive_settings = {"GOOGLE_DRIVE_USE_OAUTH": str(use_oauth_bool).lower()}
|
drive_settings = {"GOOGLE_DRIVE_USE_OAUTH": str(use_oauth_bool).lower()}
|
||||||
|
|
||||||
@@ -376,7 +446,9 @@ async def save_dropbox_settings(
|
|||||||
stripped_line = line.rstrip()
|
stripped_line = line.rstrip()
|
||||||
is_updated = False
|
is_updated = False
|
||||||
for key, value in drive_settings.items():
|
for key, value in drive_settings.items():
|
||||||
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
|
if stripped_line.startswith(
|
||||||
|
f"{key}="
|
||||||
|
) or stripped_line.startswith(f"# {key}="):
|
||||||
# Uncomment if commented out - check the original stripped line
|
# Uncomment if commented out - check the original stripped line
|
||||||
new_env_lines.append(f"{key}={value}")
|
new_env_lines.append(f"{key}={value}")
|
||||||
updated.add(key)
|
updated.add(key)
|
||||||
@@ -396,7 +468,9 @@ async def save_dropbox_settings(
|
|||||||
|
|
||||||
logger.info("Successfully updated Google Drive settings in .env file")
|
logger.info("Successfully updated Google Drive settings in .env file")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update")
|
logger.warning(
|
||||||
|
f"Failed to update .env file: {str(e)}, but will continue with in-memory update"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
|
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
|
||||||
@@ -415,7 +489,33 @@ async def save_dropbox_settings(
|
|||||||
# Set OAuth flag
|
# Set OAuth flag
|
||||||
settings.google_drive_use_oauth = use_oauth_bool
|
settings.google_drive_use_oauth = use_oauth_bool
|
||||||
|
|
||||||
logger.info("Successfully updated Google Drive settings in memory")
|
# Persist to database
|
||||||
|
save_setting_to_db(
|
||||||
|
db,
|
||||||
|
"google_drive_use_oauth",
|
||||||
|
str(use_oauth_bool).lower(),
|
||||||
|
changed_by=changed_by,
|
||||||
|
)
|
||||||
|
if refresh_token:
|
||||||
|
save_setting_to_db(
|
||||||
|
db, "google_drive_refresh_token", refresh_token, changed_by=changed_by
|
||||||
|
)
|
||||||
|
if client_id:
|
||||||
|
save_setting_to_db(
|
||||||
|
db, "google_drive_client_id", client_id, changed_by=changed_by
|
||||||
|
)
|
||||||
|
if client_secret:
|
||||||
|
save_setting_to_db(
|
||||||
|
db, "google_drive_client_secret", client_secret, changed_by=changed_by
|
||||||
|
)
|
||||||
|
if folder_id:
|
||||||
|
save_setting_to_db(
|
||||||
|
db, "google_drive_folder_id", folder_id, changed_by=changed_by
|
||||||
|
)
|
||||||
|
|
||||||
|
notify_settings_updated()
|
||||||
|
|
||||||
|
logger.info("Successfully updated Google Drive settings in memory and database")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -426,5 +526,6 @@ async def save_dropbox_settings(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Unexpected error saving Google Drive settings: {str(e)}")
|
logger.exception(f"Unexpected error saving Google Drive settings: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Google Drive settings: {str(e)}"
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to save Google Drive settings: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|||||||
+190
-75
@@ -8,11 +8,15 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
import requests
|
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.auth import require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
from app.utils.oauth_helper import exchange_oauth_token
|
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
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -47,10 +51,15 @@ async def exchange_onedrive_token(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Use shared OAuth helper (handles secure logging and error handling)
|
# 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 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")
|
@router.get("/onedrive/test-token")
|
||||||
@@ -68,7 +77,10 @@ async def test_onedrive_token(request: Request):
|
|||||||
or not settings.onedrive_client_secret
|
or not settings.onedrive_client_secret
|
||||||
):
|
):
|
||||||
logger.warning("OneDrive credentials not fully configured")
|
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
|
# Refresh token to get a new access token and expiration info
|
||||||
tenant_id = settings.onedrive_tenant_id or "common"
|
tenant_id = settings.onedrive_tenant_id or "common"
|
||||||
@@ -82,27 +94,39 @@ async def test_onedrive_token(request: Request):
|
|||||||
"scope": "offline_access Files.ReadWrite",
|
"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:
|
if response.status_code != 200:
|
||||||
logger.error(f"Failed to refresh OneDrive token: {response.text}")
|
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()
|
token_data = response.json()
|
||||||
access_token = token_data.get("access_token")
|
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)
|
# Check if we got a new refresh token (Microsoft sometimes issues a new one)
|
||||||
new_refresh_token = token_data.get("refresh_token")
|
new_refresh_token = token_data.get("refresh_token")
|
||||||
if new_refresh_token and new_refresh_token != settings.onedrive_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
|
# Update refresh token in memory
|
||||||
settings.onedrive_refresh_token = new_refresh_token
|
settings.onedrive_refresh_token = new_refresh_token
|
||||||
|
|
||||||
# Also try to update .env file if it exists
|
# Also try to update .env file if it exists
|
||||||
try:
|
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):
|
if os.path.exists(env_path):
|
||||||
with open(env_path, "r") as f:
|
with open(env_path, "r") as f:
|
||||||
env_lines = f.readlines()
|
env_lines = f.readlines()
|
||||||
@@ -112,13 +136,17 @@ async def test_onedrive_token(request: Request):
|
|||||||
|
|
||||||
for line in env_lines:
|
for line in env_lines:
|
||||||
if line.startswith("ONEDRIVE_REFRESH_TOKEN="):
|
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
|
updated = True
|
||||||
else:
|
else:
|
||||||
updated_lines.append(line)
|
updated_lines.append(line)
|
||||||
|
|
||||||
if not updated:
|
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:
|
with open(env_path, "w") as f:
|
||||||
f.writelines(updated_lines)
|
f.writelines(updated_lines)
|
||||||
@@ -128,14 +156,38 @@ async def test_onedrive_token(request: Request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to update refresh token in .env file: {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
|
# Test the access token by getting user information
|
||||||
user_info_url = "https://graph.microsoft.com/v1.0/me"
|
user_info_url = "https://graph.microsoft.com/v1.0/me"
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
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:
|
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 {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": f"Token validation failed with status {user_response.status_code}: {user_response.text}",
|
"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,
|
client_secret: Annotated[Optional[str], Form()] = None,
|
||||||
tenant_id: Annotated[str, Form()] = "common",
|
tenant_id: Annotated[str, Form()] = "common",
|
||||||
folder_path: Annotated[Optional[str], Form()] = None,
|
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:
|
try:
|
||||||
# Get the path to the .env file
|
user = request.session.get("user", {}) if hasattr(request, "session") else {}
|
||||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
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):
|
# Best-effort .env file write
|
||||||
logger.error(f".env file not found at {env_path}")
|
try:
|
||||||
raise HTTPException(
|
env_path = os.path.join(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update"
|
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
|
onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token}
|
||||||
with open(env_path, "r") as f:
|
if client_id:
|
||||||
env_lines = f.readlines()
|
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
|
updated = set()
|
||||||
onedrive_settings = {
|
new_env_lines = []
|
||||||
"ONEDRIVE_REFRESH_TOKEN": refresh_token,
|
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
|
for key, value in onedrive_settings.items():
|
||||||
if client_id:
|
if key not in updated:
|
||||||
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
|
new_env_lines.append(f"{key}={value}")
|
||||||
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
|
|
||||||
|
|
||||||
# Process each line and update or add settings
|
with open(env_path, "w") as f:
|
||||||
updated = set()
|
f.write("\n".join(new_env_lines) + "\n")
|
||||||
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)
|
|
||||||
|
|
||||||
# Add any settings that weren't updated (they weren't in the file)
|
logger.info("Successfully updated OneDrive settings in .env file")
|
||||||
for key, value in onedrive_settings.items():
|
except Exception as env_err:
|
||||||
if key not in updated:
|
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
|
||||||
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")
|
|
||||||
|
|
||||||
# Update the settings in memory
|
# Update the settings in memory
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
@@ -275,16 +334,38 @@ async def save_onedrive_settings(
|
|||||||
if folder_path:
|
if folder_path:
|
||||||
settings.onedrive_folder_path = 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"}
|
return {"status": "success", "message": "OneDrive settings have been saved"}
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Unexpected error saving OneDrive settings: {str(e)}")
|
logger.exception(f"Unexpected error saving OneDrive settings: {str(e)}")
|
||||||
raise HTTPException(
|
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,
|
client_secret: Annotated[Optional[str], Form()] = None,
|
||||||
tenant_id: Annotated[str, Form()] = "common",
|
tenant_id: Annotated[str, Form()] = "common",
|
||||||
folder_path: Annotated[Optional[str], Form()] = None,
|
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:
|
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:
|
if refresh_token:
|
||||||
settings.onedrive_refresh_token = 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:
|
if client_id:
|
||||||
settings.onedrive_client_id = 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:
|
if client_secret:
|
||||||
settings.onedrive_client_secret = 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:
|
if tenant_id:
|
||||||
settings.onedrive_tenant_id = 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:
|
if folder_path:
|
||||||
settings.onedrive_folder_path = 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
|
# Test the token to make sure it works
|
||||||
try:
|
try:
|
||||||
@@ -333,14 +441,21 @@ async def update_onedrive_settings(
|
|||||||
logger.info("Successfully tested OneDrive token")
|
logger.info("Successfully tested OneDrive token")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Token test failed after updating settings: {str(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:
|
except Exception as e:
|
||||||
logger.exception(f"Unexpected error updating OneDrive settings: {str(e)}")
|
logger.exception(f"Unexpected error updating OneDrive settings: {str(e)}")
|
||||||
raise HTTPException(
|
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)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -450,3 +450,61 @@ 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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1287,3 +1287,38 @@ def rollback_setting(
|
|||||||
)
|
)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_for_export(db: Session, source: str = "db") -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Collect settings for export as environment variables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
source: ``"db"`` to export only database-persisted settings (default);
|
||||||
|
``"effective"`` to export the full current runtime configuration
|
||||||
|
(DB overrides ENV overrides application defaults) for every key
|
||||||
|
listed in SETTING_METADATA.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ordered dict mapping uppercase ENV variable names to their string values.
|
||||||
|
Sensitive values are included (the caller is responsible for access control).
|
||||||
|
"""
|
||||||
|
if source == "effective":
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
|
db_settings = get_all_settings_from_db(db)
|
||||||
|
result = {}
|
||||||
|
for key in sorted(SETTING_METADATA.keys()):
|
||||||
|
# DB wins, then live settings object (ENV/default)
|
||||||
|
if key in db_settings and db_settings[key] is not None:
|
||||||
|
value = db_settings[key]
|
||||||
|
else:
|
||||||
|
value = getattr(app_settings, key, None)
|
||||||
|
if value is not None:
|
||||||
|
result[key.upper()] = str(value)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# DB only
|
||||||
|
db_settings = get_all_settings_from_db(db)
|
||||||
|
return {k.upper(): v for k, v in sorted(db_settings.items()) if v is not None}
|
||||||
|
|||||||
+66
-4
@@ -10,6 +10,7 @@ from fastapi.responses import RedirectResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.utils.settings_service import save_setting_to_db
|
from app.utils.settings_service import save_setting_to_db
|
||||||
|
from app.utils.settings_sync import notify_settings_updated
|
||||||
from app.utils.setup_wizard import get_wizard_steps
|
from app.utils.setup_wizard import get_wizard_steps
|
||||||
from app.views.base import APIRouter, get_db, templates
|
from app.views.base import APIRouter, get_db, templates
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/setup")
|
@router.get("/setup")
|
||||||
async def setup_wizard(request: Request, step: int = 1):
|
async def setup_wizard(request: Request, step: int = 1, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Setup wizard for first-time configuration.
|
Setup wizard for first-time configuration.
|
||||||
|
|
||||||
@@ -39,7 +40,38 @@ async def setup_wizard(request: Request, step: int = 1):
|
|||||||
current_settings = wizard_steps.get(step, [])
|
current_settings = wizard_steps.get(step, [])
|
||||||
|
|
||||||
# Get step category (all settings in a step should have same category)
|
# Get step category (all settings in a step should have same category)
|
||||||
step_category = current_settings[0].get("wizard_category", "Configuration") if current_settings else "Configuration"
|
step_category = (
|
||||||
|
current_settings[0].get("wizard_category", "Configuration")
|
||||||
|
if current_settings
|
||||||
|
else "Configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich settings with current live values
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
from app.utils.settings_service import get_setting_from_db
|
||||||
|
|
||||||
|
enriched_settings = []
|
||||||
|
for s in current_settings:
|
||||||
|
key = s["key"]
|
||||||
|
db_val = get_setting_from_db(db, key)
|
||||||
|
env_val = getattr(app_settings, key, None)
|
||||||
|
# Determine current_value and source
|
||||||
|
if db_val is not None:
|
||||||
|
current_value = db_val
|
||||||
|
value_source = "db"
|
||||||
|
elif env_val is not None and str(env_val).strip():
|
||||||
|
current_value = str(env_val)
|
||||||
|
value_source = "env"
|
||||||
|
elif s.get("default") is not None:
|
||||||
|
current_value = s["default"]
|
||||||
|
value_source = "default"
|
||||||
|
else:
|
||||||
|
current_value = ""
|
||||||
|
value_source = "none"
|
||||||
|
enriched_settings.append(
|
||||||
|
{**s, "current_value": current_value, "value_source": value_source}
|
||||||
|
)
|
||||||
|
current_settings = enriched_settings
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"setup_wizard.html",
|
"setup_wizard.html",
|
||||||
@@ -50,12 +82,15 @@ async def setup_wizard(request: Request, step: int = 1):
|
|||||||
"settings": current_settings,
|
"settings": current_settings,
|
||||||
"step_category": step_category,
|
"step_category": step_category,
|
||||||
"progress_percent": int((step / max_step) * 100),
|
"progress_percent": int((step / max_step) * 100),
|
||||||
|
"setup_skipped": bool(get_setting_from_db(db, "_setup_wizard_skipped")),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/setup")
|
@router.post("/setup")
|
||||||
async def setup_wizard_save(request: Request, step: int = Form(...), db: Session = Depends(get_db)):
|
async def setup_wizard_save(
|
||||||
|
request: Request, step: int = Form(...), db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Save settings from the current wizard step.
|
Save settings from the current wizard step.
|
||||||
"""
|
"""
|
||||||
@@ -87,6 +122,9 @@ async def setup_wizard_save(request: Request, step: int = Form(...), db: Session
|
|||||||
|
|
||||||
logger.info(f"Setup wizard step {step}: Saved {saved_count} settings")
|
logger.info(f"Setup wizard step {step}: Saved {saved_count} settings")
|
||||||
|
|
||||||
|
if saved_count > 0:
|
||||||
|
notify_settings_updated()
|
||||||
|
|
||||||
# Determine next step
|
# Determine next step
|
||||||
max_step = max(wizard_steps.keys())
|
max_step = max(wizard_steps.keys())
|
||||||
next_step = step + 1
|
next_step = step + 1
|
||||||
@@ -100,7 +138,9 @@ async def setup_wizard_save(request: Request, step: int = Form(...), db: Session
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving wizard settings: {e}")
|
logger.error(f"Error saving wizard settings: {e}")
|
||||||
return RedirectResponse(url=f"/setup?step={step}&error=save_failed", status_code=303)
|
return RedirectResponse(
|
||||||
|
url=f"/setup?step={step}&error=save_failed", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/setup/skip")
|
@router.get("/setup/skip")
|
||||||
@@ -122,3 +162,25 @@ async def setup_wizard_skip(request: Request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error skipping setup wizard: {e}")
|
logger.error(f"Error skipping setup wizard: {e}")
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/setup/undo-skip")
|
||||||
|
async def setup_wizard_undo_skip(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Undo a previously skipped setup wizard.
|
||||||
|
|
||||||
|
Removes the skip marker from the database so the wizard will be
|
||||||
|
presented again on next visit to the home page. Redirects to
|
||||||
|
step 1 of the wizard immediately.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.utils.settings_service import delete_setting_from_db
|
||||||
|
|
||||||
|
delete_setting_from_db(
|
||||||
|
db, "_setup_wizard_skipped", changed_by="wizard_undo_skip"
|
||||||
|
)
|
||||||
|
logger.info("Setup wizard skip marker removed; redirecting to wizard")
|
||||||
|
return RedirectResponse(url="/setup?step=1", status_code=303)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error undoing setup wizard skip: {e}")
|
||||||
|
return RedirectResponse(url="/settings", status_code=303)
|
||||||
|
|||||||
@@ -20,10 +20,36 @@
|
|||||||
This is a convenience feature to view and edit application settings through the web interface.
|
This is a convenience feature to view and edit application settings through the web interface.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/admin/settings/audit-log"
|
<div class="flex items-center gap-2">
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
<a href="/setup?step=1"
|
||||||
<i class="fas fa-history mr-2"></i> Audit Log
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
</a>
|
<i class="fas fa-magic mr-2"></i> Setup Wizard
|
||||||
|
</a>
|
||||||
|
<div class="relative" x-data="{ exportOpen: false }">
|
||||||
|
<button @click="exportOpen = !exportOpen"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-download mr-2"></i> Export .env
|
||||||
|
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<div x-show="exportOpen" @click.outside="exportOpen = false" x-transition
|
||||||
|
class="absolute right-0 mt-1 w-52 bg-white border border-gray-200 rounded-md shadow-lg z-10">
|
||||||
|
<a href="/api/settings/export-env?source=db"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
@click="exportOpen = false">
|
||||||
|
<i class="fas fa-database mr-2 text-green-600"></i>DB settings only
|
||||||
|
</a>
|
||||||
|
<a href="/api/settings/export-env?source=effective"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
@click="exportOpen = false">
|
||||||
|
<i class="fas fa-layer-group mr-2 text-blue-600"></i>Full effective config
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/settings/audit-log"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
<i class="fas fa-history mr-2"></i> Audit Log
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-blue-50 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
|
<div class="bg-blue-50 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
|
||||||
<p class="font-bold">📋 Settings Precedence Order:</p>
|
<p class="font-bold">📋 Settings Precedence Order:</p>
|
||||||
|
|||||||
@@ -134,13 +134,21 @@
|
|||||||
type="{% if setting.sensitive %}password{% else %}text{% endif %}"
|
type="{% if setting.sensitive %}password{% else %}text{% endif %}"
|
||||||
id="{{ setting.key }}"
|
id="{{ setting.key }}"
|
||||||
name="{{ setting.key }}"
|
name="{{ setting.key }}"
|
||||||
value="{{ setting.default if setting.default else '' }}"
|
value="{{ setting.current_value if setting.current_value else '' }}"
|
||||||
class="wizard-input w-full px-4 py-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
class="wizard-input w-full px-4 py-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
placeholder="{{ setting.description }}"
|
placeholder="{{ setting.description }}"
|
||||||
{% if setting.default is none or setting.key in ['admin_password', 'openai_api_key', 'azure_ai_key', 'azure_endpoint'] %}required{% endif %}
|
{% if setting.default is none or setting.key in ['admin_password', 'openai_api_key', 'azure_ai_key', 'azure_endpoint'] %}required{% endif %}
|
||||||
/>
|
/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if setting.value_source == 'db' %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 mt-1">DB</span>
|
||||||
|
{% elif setting.value_source == 'env' %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 mt-1">ENV</span>
|
||||||
|
{% elif setting.value_source == 'default' %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 mt-1">DEFAULT</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if setting.key == 'admin_password' %}
|
{% if setting.key == 'admin_password' %}
|
||||||
<p class="mt-2 text-xs text-amber-600">
|
<p class="mt-2 text-xs text-amber-600">
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
@@ -159,7 +167,12 @@
|
|||||||
<!-- Navigation Buttons -->
|
<!-- Navigation Buttons -->
|
||||||
<div class="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
<div class="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
||||||
<div>
|
<div>
|
||||||
{% if current_step == 1 %}
|
{% if setup_skipped %}
|
||||||
|
<span class="text-sm text-amber-600 font-medium">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
Setup was previously skipped.
|
||||||
|
</span>
|
||||||
|
{% elif current_step == 1 %}
|
||||||
<a href="/setup/skip" class="text-sm text-gray-600 hover:text-gray-900">
|
<a href="/setup/skip" class="text-sm text-gray-600 hover:text-gray-900">
|
||||||
<i class="fas fa-forward"></i>
|
<i class="fas fa-forward"></i>
|
||||||
Skip setup (advanced users)
|
Skip setup (advanced users)
|
||||||
@@ -200,6 +213,12 @@
|
|||||||
<p class="text-xs text-gray-500 mt-2">
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
Fields marked with <span class="text-red-600">*</span> are required.
|
Fields marked with <span class="text-red-600">*</span> are required.
|
||||||
</p>
|
</p>
|
||||||
|
{% if setup_skipped %}
|
||||||
|
<p class="text-xs text-amber-600 mt-2">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
You previously skipped the setup wizard. <a href="/setup/undo-skip" class="underline hover:text-amber-800">Click here to re-run it.</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user