Merge pull request #36 from christianlouis/OneDrive

Enhance FTP configuration and OneDrive workflow
This commit is contained in:
Christian Krakau-Louis
2025-04-02 03:02:25 +02:00
committed by GitHub
15 changed files with 1242 additions and 45 deletions
+2
View File
@@ -104,6 +104,8 @@ FTP_PORT=21
FTP_USERNAME=ftp_user
FTP_PASSWORD=your_secure_ftp_password
FTP_FOLDER=/Documents/Uploads
FTP_USE_TLS=True
FTP_ALLOW_PLAINTEXT=True
# SFTP
SFTP_HOST=sftp.example.com
+370 -3
View File
@@ -1,9 +1,11 @@
# app/api.py
from fastapi import APIRouter, Request, HTTPException, status, Depends, UploadFile, File
from fastapi import APIRouter, Request, HTTPException, status, Depends, UploadFile, File, Form
from hashlib import md5
from sqlalchemy.orm import Session
from typing import List
import os
import requests
import logging
from app.auth import require_login, get_current_user
from app.database import SessionLocal
@@ -14,8 +16,12 @@ from app.tasks.upload_to_dropbox import upload_to_dropbox
from app.tasks.upload_to_paperless import upload_to_paperless
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
from app.tasks.upload_to_google_drive import upload_to_google_drive
from app.tasks.upload_to_onedrive import upload_to_onedrive
from app.tasks.send_to_all import send_to_all_destinations
# Set up logging
logger = logging.getLogger(__name__)
router = APIRouter()
def get_db():
@@ -186,6 +192,19 @@ def send_to_google_drive(file_path: str):
task = upload_to_google_drive.delay(file_path)
return {"task_id": task.id, "status": "queued"}
@router.post("/send_to_onedrive/")
@require_login
def send_to_onedrive_endpoint(file_path: str):
"""Send a document to OneDrive."""
if not os.path.isabs(file_path):
file_path = os.path.join(settings.workdir, 'processed', file_path)
if not os.path.exists(file_path):
raise HTTPException(
status_code=400, detail=f"File {file_path} not found."
)
task = upload_to_onedrive.delay(file_path)
return {"task_id": task.id, "status": "queued"}
@router.post("/send_to_all_destinations/")
@require_login
def send_to_all_destinations_endpoint(file_path: str):
@@ -276,5 +295,353 @@ async def ui_upload(request: Request, file: UploadFile = File(...)):
"stored_filename": target_filename
}
# Note: The api/router.py is now a submodule organization,
# but we're keeping this file for compatibility until we've fully migrated
@router.post("/onedrive/exchange-token")
@require_login
async def exchange_onedrive_token(
request: Request,
client_id: str = Form(...),
client_secret: str = Form(...),
redirect_uri: str = Form(...),
code: str = Form(...),
tenant_id: str = Form(...)
):
"""
Exchange an authorization code for a refresh token.
This is done on the server to avoid exposing client secret in the browser.
"""
try:
logger.info(f"Starting OneDrive token exchange process with tenant_id: {tenant_id}")
# Prepare the token request
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
logger.info(f"Using token URL: {token_url}")
payload = {
'client_id': client_id,
'scope': 'https://graph.microsoft.com/.default offline_access',
'code': code,
'redirect_uri': redirect_uri,
'grant_type': 'authorization_code',
'client_secret': client_secret
}
# Log request details (excluding secret)
safe_payload = payload.copy()
safe_payload['client_secret'] = '[REDACTED]'
safe_payload['code'] = f"{code[:5]}...{code[-5:]}" if len(code) > 10 else '[REDACTED]'
logger.info(f"Token exchange request payload: {safe_payload}")
# Make the token request
logger.info("Sending POST request to Microsoft for token exchange")
response = requests.post(token_url, data=payload)
# Check if the request was successful
logger.info(f"Token exchange response status: {response.status_code}")
if response.status_code != 200:
# Log the error response for debugging
try:
error_json = response.json()
logger.error(f"Token exchange failed with status {response.status_code}: {error_json}")
error_detail = error_json
except Exception as json_err:
logger.error(f"Failed to parse error response as JSON: {str(json_err)}")
logger.error(f"Raw response content: {response.content[:500]}") # Limit log size
error_detail = {"error": "Unknown error", "raw_content_snippet": str(response.content[:100])}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Token exchange failed: {error_detail}"
)
# Return the token response
token_data = response.json()
# Validate the token response
if "refresh_token" not in token_data:
logger.error(f"Microsoft returned success but no refresh token found in response: {token_data.keys()}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Microsoft OAuth server returned success but no refresh token was included"
)
# Calculate token length for logging
refresh_token_length = len(token_data.get("refresh_token", ""))
access_token_length = len(token_data.get("access_token", ""))
logger.info(f"Successfully exchanged authorization code for OneDrive tokens. "
f"Refresh token length: {refresh_token_length}, "
f"Access token length: {access_token_length}")
# Return just what's needed by the frontend
return {
"refresh_token": token_data["refresh_token"],
"expires_in": token_data.get("expires_in", 3600)
}
except HTTPException:
# Re-raise HTTP exceptions as they already have appropriate status codes
raise
except Exception as e:
logger.exception(f"Unexpected error during OneDrive token exchange: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to exchange token: {str(e)}"
)
@router.get("/onedrive/test-token")
@require_login
async def test_onedrive_token(request: Request):
"""
Test if the configured OneDrive refresh token is valid.
Provides detailed error information if token is invalid.
"""
try:
from app.tasks.upload_to_onedrive import get_onedrive_token
logger.info("Testing OneDrive token validity")
if not settings.onedrive_refresh_token:
logger.warning("No OneDrive refresh token configured")
return {
"status": "error",
"message": "No OneDrive refresh token is configured"
}
# Check if client ID and client secret are configured
if not settings.onedrive_client_id or not settings.onedrive_client_secret:
logger.warning("OneDrive client ID or client secret is missing")
return {
"status": "error",
"message": "OneDrive client ID or client secret is missing",
"missing_config": True
}
# Try to get an access token using the configured refresh token
try:
access_token = get_onedrive_token()
# If we got here, token is valid
logger.info("OneDrive token is valid")
return {
"status": "success",
"message": "OneDrive token is valid",
}
except Exception as e:
error_msg = str(e)
logger.error(f"OneDrive token test failed: {error_msg}")
# Determine if this is an invalid_grant error (expired token)
is_expired = "invalid_grant" in error_msg.lower()
return {
"status": "error",
"message": f"Token validation failed: {error_msg}",
"is_expired": is_expired,
"needs_reauth": True
}
except Exception as e:
logger.exception("Unexpected error testing OneDrive token")
return {
"status": "error",
"message": f"Unexpected error: {str(e)}"
}
@router.post("/onedrive/save-settings")
@require_login
async def save_onedrive_settings(
request: Request,
client_id: str = Form(None),
client_secret: str = Form(None),
refresh_token: str = Form(...),
tenant_id: str = Form("common"),
folder_path: str = Form(None)
):
"""
Save OneDrive settings to the .env file
"""
try:
# Get the path to the .env file
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
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"
)
logger.info(f"Updating OneDrive settings in {env_path}")
# Read the current .env file
with open(env_path, "r") as f:
env_lines = f.readlines()
# Define settings to update
onedrive_settings = {
"ONEDRIVE_REFRESH_TOKEN": refresh_token,
}
# 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
# Process each line and update or add settings
updated = set()
new_env_lines = []
for line in env_lines:
line = line.rstrip()
is_updated = False
for key, value in onedrive_settings.items():
if line.startswith(f"{key}=") or line.startswith(f"# {key}="):
if line.startswith("# "): # Uncomment if commented out
line = line[2:]
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(line)
# 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")
# Update the settings in memory
if refresh_token:
settings.onedrive_refresh_token = refresh_token
if client_id:
settings.onedrive_client_id = client_id
if client_secret:
settings.onedrive_client_secret = client_secret
if tenant_id:
settings.onedrive_tenant_id = tenant_id
if folder_path:
settings.onedrive_folder_path = folder_path
logger.info("Successfully updated 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)}"
)
@router.post("/onedrive/update-settings")
@require_login
async def update_onedrive_settings(
request: Request,
client_id: str = Form(None),
client_secret: str = Form(None),
refresh_token: str = Form(...),
tenant_id: str = Form("common"),
folder_path: str = Form(None)
):
"""
Update OneDrive settings in memory (without modifying .env file)
"""
try:
logger.info("Updating OneDrive settings in memory")
# Update settings in memory
if refresh_token:
settings.onedrive_refresh_token = refresh_token
logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory")
if client_id:
settings.onedrive_client_id = client_id
logger.info("Updated ONEDRIVE_CLIENT_ID in memory")
if client_secret:
settings.onedrive_client_secret = client_secret
logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory")
if tenant_id:
settings.onedrive_tenant_id = tenant_id
logger.info("Updated ONEDRIVE_TENANT_ID in memory")
if folder_path:
settings.onedrive_folder_path = folder_path
logger.info("Updated ONEDRIVE_FOLDER_PATH in memory")
# Test the token to make sure it works
try:
from app.tasks.upload_to_onedrive import get_onedrive_token
access_token = get_onedrive_token()
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": "success",
"message": "OneDrive settings have been updated in memory"
}
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)}"
)
@router.get("/onedrive/get-full-config")
@require_login
async def get_onedrive_full_config(request: Request):
"""
Get the full OneDrive configuration for sharing with worker nodes
"""
try:
# Create a configuration object with all OneDrive settings
config = {
"client_id": settings.onedrive_client_id or "",
"client_secret": settings.onedrive_client_secret or "",
"tenant_id": settings.onedrive_tenant_id or "common",
"refresh_token": settings.onedrive_refresh_token or "",
"folder_path": settings.onedrive_folder_path or "Documents/Uploads"
}
# Generate environment variable format
env_format = "\n".join([
f"ONEDRIVE_CLIENT_ID={config['client_id']}",
f"ONEDRIVE_CLIENT_SECRET={config['client_secret']}",
f"ONEDRIVE_TENANT_ID={config['tenant_id']}",
f"ONEDRIVE_REFRESH_TOKEN={config['refresh_token']}",
f"ONEDRIVE_FOLDER_PATH={config['folder_path']}"
])
return {
"status": "success",
"config": config,
"env_format": env_format
}
except Exception as e:
logger.exception("Error getting OneDrive configuration")
return {
"status": "error",
"message": str(e)
}
+2
View File
@@ -77,6 +77,8 @@ class Settings(BaseSettings):
ftp_username: Optional[str] = None
ftp_password: Optional[str] = None
ftp_folder: Optional[str] = None
ftp_use_tls: bool = True # Default to attempting TLS connection first
ftp_allow_plaintext: bool = True # Default to allowing plaintext fallback
# SFTP settings
sftp_host: Optional[str] = None
+64 -2
View File
@@ -4,6 +4,7 @@ from fastapi.templating import Jinja2Templates
from pathlib import Path
from sqlalchemy.orm import Session
from datetime import datetime
import logging
from app.auth import require_login
from app.database import SessionLocal
@@ -15,6 +16,9 @@ router = APIRouter()
templates_dir = Path(__file__).parent.parent / "frontend" / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Set up logging
logger = logging.getLogger(__name__)
def get_db():
db = SessionLocal()
try:
@@ -95,5 +99,63 @@ async def env_debug(request: Request):
}
)
# Note: The frontend/router.py is now a submodule organization,
# but we're keeping this file for compatibility until we've fully migrated
@router.get("/onedrive-setup")
@require_login
async def onedrive_setup_page(request: Request):
"""
Setup page for the OneDrive integration.
Shows configuration status and setup instructions.
"""
# Check OneDrive configuration
is_configured = bool(settings.onedrive_client_id and
settings.onedrive_client_secret and
settings.onedrive_refresh_token)
# Get configuration values to display status (hide sensitive values)
return templates.TemplateResponse(
"onedrive.html",
{
"request": request,
"is_configured": is_configured,
"client_id": bool(settings.onedrive_client_id),
"client_id_value": settings.onedrive_client_id or "", # Pass the actual value for the form
"client_secret": bool(settings.onedrive_client_secret),
"client_secret_value": settings.onedrive_client_secret if settings.onedrive_client_secret else "",
"tenant_id": settings.onedrive_tenant_id,
"refresh_token": bool(settings.onedrive_refresh_token),
"refresh_token_value": settings.onedrive_refresh_token if settings.onedrive_refresh_token else "",
"folder_path": settings.onedrive_folder_path or "Documents/Uploads" # Default folder path
}
)
@router.get("/onedrive-callback")
@require_login
async def onedrive_callback(request: Request, code: str = None, error: str = None):
"""
Callback endpoint for OneDrive OAuth flow.
Now automatically exchanges the code for a token and saves it to the configuration.
"""
if error:
return templates.TemplateResponse(
"onedrive_callback_error.html",
{"request": request, "error": error}
)
if not code:
return templates.TemplateResponse(
"onedrive_callback_error.html",
{"request": request, "error": "No authorization code received from Microsoft"}
)
# Display the processing page with automatic token exchange
return templates.TemplateResponse(
"onedrive_callback.html",
{
"request": request,
"code": code,
"client_id_value": settings.onedrive_client_id or "",
"client_secret_value": settings.onedrive_client_secret or "",
"tenant_id": settings.onedrive_tenant_id or "common"
}
)
+61 -12
View File
@@ -26,18 +26,66 @@ def upload_to_ftp(file_path: str):
raise ValueError(error_msg)
try:
# Connect to FTP server
ftp = ftplib.FTP()
ftp.connect(
host=settings.ftp_host,
port=settings.ftp_port or 21
)
# First attempt FTPS (FTP with TLS)
use_tls = getattr(settings, 'ftp_use_tls', True) # Default to try TLS
allow_plaintext = getattr(settings, 'ftp_allow_plaintext', True) # Default to allow plaintext fallback
# Login with credentials
ftp.login(
user=settings.ftp_username,
passwd=settings.ftp_password
)
if use_tls:
try:
logger.info(f"Attempting FTPS connection to {settings.ftp_host}")
ftp = ftplib.FTP_TLS()
ftp.connect(
host=settings.ftp_host,
port=settings.ftp_port or 21
)
# Login with credentials
ftp.login(
user=settings.ftp_username,
passwd=settings.ftp_password
)
# Enable data protection - encrypt the data channel
ftp.prot_p()
logger.info("Successfully established FTPS connection with TLS")
except Exception as e:
if not allow_plaintext:
error_msg = f"FTPS connection failed and plaintext FTP is forbidden: {str(e)}"
logger.error(error_msg)
raise Exception(error_msg)
else:
logger.warning(f"FTPS connection failed, falling back to regular FTP: {str(e)}")
# Fall back to regular FTP
ftp = ftplib.FTP()
ftp.connect(
host=settings.ftp_host,
port=settings.ftp_port or 21
)
# Login with credentials
ftp.login(
user=settings.ftp_username,
passwd=settings.ftp_password
)
else:
# Check if plaintext is allowed when TLS is explicitly disabled
if not allow_plaintext:
error_msg = "Plaintext FTP is forbidden by configuration"
logger.error(error_msg)
raise Exception(error_msg)
# Directly use regular FTP if TLS is explicitly disabled
ftp = ftplib.FTP()
ftp.connect(
host=settings.ftp_host,
port=settings.ftp_port or 21
)
# Login with credentials
ftp.login(
user=settings.ftp_username,
passwd=settings.ftp_password
)
# Change to target directory if specified
if settings.ftp_folder:
@@ -80,7 +128,8 @@ def upload_to_ftp(file_path: str):
"status": "Completed",
"file": file_path,
"ftp_host": settings.ftp_host,
"ftp_path": f"{settings.ftp_folder}/{filename}" if settings.ftp_folder else filename
"ftp_path": f"{settings.ftp_folder}/{filename}" if settings.ftp_folder else filename,
"used_tls": isinstance(ftp, ftplib.FTP_TLS)
}
except Exception as e:
+35 -4
View File
@@ -21,22 +21,53 @@ def get_onedrive_token():
if not settings.onedrive_client_id or not settings.onedrive_client_secret:
raise ValueError("OneDrive client ID and client secret must be configured")
# Log more details about the configuration
tenant = settings.onedrive_tenant_id or "common"
logger.info(f"Using OneDrive tenant: {tenant}")
# Define scopes consistently
scopes = ["https://graph.microsoft.com/.default"]
# Use refresh token flow (works for both personal and org accounts)
if settings.onedrive_refresh_token:
# Use MSAL to get token from refresh token
app = msal.PublicClientApplication(settings.onedrive_client_id)
# Use MSAL's ConfidentialClientApplication instead of PublicClientApplication
app = msal.ConfidentialClientApplication(
client_id=settings.onedrive_client_id,
client_credential=settings.onedrive_client_secret,
authority=f"https://login.microsoftonline.com/{tenant}"
)
# Request new token using refresh token
logger.info("Attempting to acquire token using refresh token")
token_response = app.acquire_token_by_refresh_token(
refresh_token=settings.onedrive_refresh_token,
scopes=["https://graph.microsoft.com/Files.ReadWrite"]
scopes=scopes
)
if "access_token" not in token_response:
error = token_response.get("error", "")
error_desc = token_response.get("error_description", "Unknown error")
# Log more details about the error
logger.error(f"Failed to get access token using refresh token")
logger.error(f"Error code: {error}")
logger.error(f"Error description: {error_desc}")
if error == "invalid_grant":
logger.error("The refresh token appears to be expired or revoked")
logger.error("A new authorization flow is required to obtain a fresh token")
raise ValueError(f"Failed to get access token: {error} - {error_desc}")
# Check if we received a new refresh token and update it
if "refresh_token" in token_response:
new_refresh_token = token_response["refresh_token"]
logger.info("Received new refresh token from Microsoft")
# Update the refresh token in memory
settings.onedrive_refresh_token = new_refresh_token
logger.info("Updated refresh token in memory")
return token_response["access_token"]
# No refresh token - try client credentials (only works for org accounts)
@@ -50,7 +81,7 @@ def get_onedrive_token():
# Acquire token for application
token_response = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
scopes=scopes
)
if "access_token" not in token_response:
+18 -1
View File
@@ -239,6 +239,21 @@ def get_provider_status():
}
}
# Add FTP configuration to providers
providers["FTP Storage"] = {
"name": "FTP Storage",
"configured": bool(getattr(settings, 'ftp_host', None) and
getattr(settings, 'ftp_username', None) and
getattr(settings, 'ftp_password', None)),
"enabled": True,
"details": {
"host": getattr(settings, 'ftp_host', 'Not set'),
"folder": getattr(settings, 'ftp_folder', 'Not set'),
"tls": getattr(settings, 'ftp_use_tls', True),
"allow_plaintext": getattr(settings, 'ftp_allow_plaintext', True)
}
}
return providers
def dump_all_settings():
@@ -363,7 +378,9 @@ def get_settings_for_display(show_values=False):
"ftp_port",
"ftp_username",
"ftp_password",
"ftp_folder"
"ftp_folder",
"ftp_use_tls",
"ftp_allow_plaintext"
],
"S3/AWS": [
"aws_access_key_id",
+3
View File
@@ -4,6 +4,7 @@ services:
context: .
dockerfile: Dockerfile
container_name: document_api
restart: always
# We'll keep the code in /app, but set working_dir to the shared data directory
working_dir: /workdir
@@ -34,6 +35,7 @@ services:
context: .
dockerfile: Dockerfile
container_name: document_worker
restart: always
# same shared working directory
working_dir: /workdir
@@ -55,6 +57,7 @@ services:
gotenberg:
image: gotenberg/gotenberg:latest
container_name: gotenberg
restart: always
redis:
+4
View File
@@ -102,6 +102,8 @@ For detailed setup instructions, see the [Google Drive Setup Guide](GoogleDriveS
| `FTP_USERNAME` | FTP authentication username. |
| `FTP_PASSWORD` | FTP authentication password. |
| `FTP_FOLDER` | Destination folder on FTP server (e.g. `"/Documents/Uploads"`). |
| `FTP_USE_TLS` | Try to use FTPS with TLS encryption first (default: `True`). |
| `FTP_ALLOW_PLAINTEXT` | Allow fallback to plaintext FTP if TLS fails (default: `True`). |
### SFTP
@@ -235,6 +237,8 @@ FTP_PORT=21
FTP_USERNAME=username
FTP_PASSWORD=password
FTP_FOLDER=/Documents/Uploads
FTP_USE_TLS=True
FTP_ALLOW_PLAINTEXT=True
# SFTP
SFTP_HOST=sftp.example.com
+61 -20
View File
@@ -14,6 +14,27 @@ This guide explains how to set up the Microsoft OneDrive integration for DocuNov
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
## Setup Methods
You can set up OneDrive integration in two ways:
1. **Using the Auth Wizard (Recommended)**: An interactive setup experience available at `/onedrive-setup` in the web interface
2. **Manual Setup**: Following the step-by-step instructions in this document
## Using the Auth Wizard
The easiest way to set up OneDrive integration is to use the built-in auth wizard:
1. Register an application in Azure AD (see steps below)
2. Navigate to the OneDrive Setup page at `/onedrive-setup`
3. Enter your Client ID and other required information
4. Click "Start Authentication Flow"
5. Complete the Microsoft authentication process
6. The system will automatically exchange the authorization code for a refresh token
7. Copy the generated environment variables for your worker nodes
The auth wizard handles all the token exchange steps and provides you with the exact configuration needed for your environment.
## Types of Microsoft Accounts
There are two main types of Microsoft accounts that can be used with OneDrive integration:
@@ -34,7 +55,9 @@ The setup process differs slightly based on which account type you're using.
5. For "Supported account types", select the appropriate option:
- For personal accounts: "Accounts in any organizational directory and personal Microsoft accounts"
- For corporate accounts only: "Accounts in this organizational directory only"
6. For Redirect URI, select "Web" and enter a URL you can access (e.g., `http://localhost:8000/auth/callback`)
6. For Redirect URI, select "Web" and enter your callback URL:
- For auth wizard: `https://your-domain.com/onedrive-callback`
- For manual setup: any URL you can access (e.g., `http://localhost:8000/auth/callback`)
7. Click "Register"
### 2. Get Application (client) ID
@@ -50,6 +73,21 @@ The setup process differs slightly based on which account type you're using.
4. Click "Add" and immediately copy the secret value (it will only be shown once)
5. Set this value as `ONEDRIVE_CLIENT_SECRET`
### 4. Configure API permissions
1. In your application page, go to "API permissions"
2. Click "Add a permission"
3. Select "Microsoft Graph" > "Delegated permissions"
4. Search for and add the following permission:
- `Files.ReadWrite` (Allows the app to read and write files that the user has access to)
- `offline_access` (Required for refresh tokens)
5. If using application permissions (for service accounts), add:
- `Files.ReadWrite.All` (Required for app-only access)
6. Click "Add permissions"
7. For organizational accounts, you may need an admin to "Grant admin consent"
> **Important Scope Change**: The system now uses the `.default` scope during authentication, which requests all permissions that have been granted to the application. This ensures that the app has all required permissions at once, rather than requesting each scope individually.
## For Personal Microsoft Accounts
If you're using a personal Microsoft account (@outlook.com, @hotmail.com, or personal accounts linked to other emails):
@@ -58,34 +96,31 @@ If you're using a personal Microsoft account (@outlook.com, @hotmail.com, or per
- Set `ONEDRIVE_TENANT_ID=common` in your configuration
### 2. Configure API permissions
### 2. Generate a Refresh Token
1. In your application page, go to "API permissions"
2. Click "Add a permission"
3. Select "Microsoft Graph" > "Delegated permissions"
4. Search for and add the following permissions:
- `Files.ReadWrite` (Allows the app to read and write files that the user has access to)
- `offline_access` (Needed for refresh tokens)
5. Click "Add permissions"
### 3. Generate a Refresh Token
#### Using the Auth Wizard (Recommended)
1. Navigate to the OneDrive Setup page at `/onedrive-setup`
2. Enter your Client ID and leave Tenant ID as "common"
3. Click "Start Authentication Flow" and follow the prompts
4. The wizard will handle token exchange automatically
#### Manual Method
1. Use the following URL (replace CLIENT_ID and REDIRECT_URI with your values):
```
https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI&response_mode=query&scope=offline_access%20Files.ReadWrite
https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REDIRECT_URI&response_mode=query&scope=https://graph.microsoft.com/.default offline_access&prompt=consent
```
2. Open this URL in your browser
3. Sign in with your personal Microsoft account
4. After authentication, you'll be redirected to your redirect URI with a code parameter in the URL
5. Copy the code value from the URL (everything after "code=")
### 4. Exchange Code for Refresh Token
### 3. Exchange Code for Refresh Token (Manual Method Only)
1. Use the following command to exchange the code for tokens:
```bash
curl -X POST https://login.microsoftonline.com/common/oauth2/v2.0/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID&scope=offline_access Files.ReadWrite&code=YOUR_AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&grant_type=authorization_code&client_secret=YOUR_CLIENT_SECRET"
-d "client_id=YOUR_CLIENT_ID&scope=https://graph.microsoft.com/.default offline_access&code=YOUR_AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&grant_type=authorization_code&client_secret=YOUR_CLIENT_SECRET"
```
2. From the response JSON, copy the `refresh_token` value
3. Set this as `ONEDRIVE_REFRESH_TOKEN` in your configuration
@@ -104,10 +139,11 @@ If you're using a work/school account provided by your organization:
**Option A: Access your own OneDrive (Interactive Login)**
This option requires a refresh token just like personal accounts:
1. Follow the same steps as for personal accounts, but use your work email to sign in
2. Make sure to set `ONEDRIVE_TENANT_ID` to your organization's tenant ID instead of "common"
3. Set the refresh token you receive as `ONEDRIVE_REFRESH_TOKEN`
This option requires a refresh token:
1. Use the auth wizard with your tenant ID, or
2. Follow the same manual steps as for personal accounts, but use your work email to sign in
3. Make sure to set `ONEDRIVE_TENANT_ID` to your organization's tenant ID instead of "common"
4. Set the refresh token you receive as `ONEDRIVE_REFRESH_TOKEN`
**Option B: Access OneDrive as a system service (App-only access)**
@@ -131,8 +167,13 @@ If you encounter errors during authentication:
- For corporate accounts, ensure an admin has consented to the permissions
3. **Refresh token expired**:
- If uploads stop working, you may need to generate a new refresh token
- Repeat the process to get a new authorization code and refresh token
- If uploads stop working, you can generate a new refresh token using the auth wizard
- Click on "Refresh Token" in the OneDrive setup page
4. **Scope issues**:
- If you see permission errors, make sure your app has the correct permissions added
- The `.default` scope is now used, which includes all permissions granted to the app
- Check that both `Files.ReadWrite` and `offline_access` permissions are added to your app
## Configuration Examples
+43 -2
View File
@@ -31,15 +31,54 @@
</div>
<!-- Menu Items -->
<div class="flex space-x-4 items-center">
<div class="hidden md:flex space-x-4 items-center">
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
<a href="/status" class="text-gray-700 hover:text-gray-900">Status</a>
<!-- Integrations Dropdown -->
<div class="relative inline-block text-left" x-data="{ open: false }">
<button @click="open = !open" class="text-gray-700 hover:text-gray-900 inline-flex items-center">
Integrations
<svg class="ml-1 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div
x-show="open"
@click.away="open = false"
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
>
<div class="py-1">
<a href="/onedrive-setup" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900">OneDrive Setup</a>
<!-- Add other integration setup links here -->
</div>
</div>
</div>
<a href="/about" class="text-gray-700 hover:text-gray-900">About</a>
<!-- Dynamic Auth Section -->
<div id="authSection" class="text-gray-700 hover:text-gray-900"></div>
</div>
<!-- Mobile menu button -->
<div class="md:hidden">
<button type="button" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</nav>
@@ -55,6 +94,8 @@
</div>
</footer>
<!-- Alpine.js for dropdown functionality -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<!-- Common JS (shared) -->
<script src="/static/js/common.js"></script>
+321
View File
@@ -0,0 +1,321 @@
{% extends "base.html" %}
{% block title %}OneDrive Setup{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">OneDrive Integration Setup</h1>
<p class="text-gray-600 mb-4">
Configure the Microsoft OneDrive integration for DocuNova using our setup wizard.
</p>
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
<p class="font-bold">Current Status:</p>
<p>OneDrive integration is
{% if is_configured %}
<span class="text-green-700 font-semibold">configured</span>.
{% else %}
<span class="text-red-700 font-semibold">not configured</span>.
{% endif %}
</p>
{% if folder_path %}
<p class="mt-2"><strong>Target folder:</strong> {{ folder_path }}</p>
{% endif %}
</div>
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 my-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
<strong>Note:</strong> You only need to create an Azure application and get credentials. The setup wizard will handle the rest automatically.
</p>
</div>
</div>
</div>
</div>
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-2xl font-semibold mb-4">Quick Setup Guide</h2>
<div class="mb-6">
<h3 class="text-xl font-medium mb-4">Step 1: Register an Azure Application</h3>
<ol class="list-decimal ml-6 space-y-3">
<li>Go to the <a href="https://portal.azure.com/" target="_blank" class="text-blue-600 hover:underline">Azure Portal</a></li>
<li>Navigate to "Azure Active Directory" > "App registrations"</li>
<li>Click "New registration"</li>
<li>Enter a name for your application (e.g., "DocuNova")</li>
<li>For "Supported account types", select:
<div class="bg-gray-50 p-2 my-2 rounded text-sm">
"Accounts in any organizational directory and personal Microsoft accounts"
</div>
</li>
<li>For Redirect URI:
<ul class="list-disc ml-6 mt-1">
<li>Select "Web"</li>
<li>Enter: <code class="bg-gray-100 p-1">{{ request.url.scheme }}://{{ request.url.netloc }}/onedrive-callback</code></li>
</ul>
</li>
<li>Click "Register"</li>
</ol>
</div>
<div class="mb-6">
<h3 class="text-xl font-medium mb-4">Step 2: Configure API Permissions</h3>
<ol class="list-decimal ml-6 space-y-3">
<li>In your application page, go to "API permissions"</li>
<li>Click "Add a permission"</li>
<li>Select "Microsoft Graph" > "Delegated permissions"</li>
<li>Search for and add these permissions:
<ul class="list-disc ml-6 mt-1">
<li><code class="bg-gray-100 p-1">Files.ReadWrite</code></li>
<li><code class="bg-gray-100 p-1">offline_access</code></li>
</ul>
</li>
<li>Click "Add permissions"</li>
</ol>
</div>
<div class="mb-6">
<h3 class="text-xl font-medium mb-4">Step 3: Get Client ID and Create Secret</h3>
<ol class="list-decimal ml-6 space-y-3">
<li>Copy the "Application (client) ID" from the overview page</li>
<li>Go to "Certificates & secrets"</li>
<li>Under "Client secrets", click "New client secret"</li>
<li>Add a description and select an expiration period</li>
<li>Click "Add" and <strong>immediately copy the secret value</strong> (it will only be shown once)</li>
</ol>
</div>
</div>
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-2xl font-semibold mb-4">Complete Setup with Wizard</h2>
<div class="space-y-4">
<div>
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value }}">
</div>
<div>
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value }}">
</div>
<div>
<label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label>
<input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}">
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
</div>
<div>
<button id="start-auth-flow" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Start Authentication Flow
</button>
</div>
<!-- Token validation and status -->
<div id="token-status" class="mt-6 {{ 'hidden' if not is_configured else '' }}">
<div class="rounded-md {{ 'bg-green-50' if is_configured else 'bg-yellow-50' }} p-4">
<div class="flex">
<div class="flex-shrink-0">
{% if is_configured %}
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{% else %}
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{% endif %}
</div>
<div class="ml-3">
<p class="text-sm font-medium {{ 'text-green-800' if is_configured else 'text-yellow-800' }}">
{% if is_configured %}
OneDrive integration is properly configured! Your refresh token is valid.
{% else %}
OneDrive integration is not completely configured or token may be invalid.
{% endif %}
</p>
</div>
</div>
</div>
<div class="mt-4 flex space-x-3">
<button id="test-token" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Test Token
</button>
<button id="refresh-token-btn" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Refresh Token
</button>
</div>
<!-- Configuration for Worker Nodes section -->
<div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes:
</p>
<div class="relative">
<pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code>ONEDRIVE_CLIENT_ID={{ client_id_value }}
ONEDRIVE_CLIENT_SECRET={{ client_secret_value|default('YOUR_CLIENT_SECRET', true) }}
ONEDRIVE_TENANT_ID={{ tenant_id }}
ONEDRIVE_REFRESH_TOKEN={{ refresh_token_value|default('YOUR_REFRESH_TOKEN', true) }}
ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code></pre>
<button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy
</button>
</div>
<p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file or environment configuration.
</p>
</div>
</div>
</div>
</div>
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-lg font-medium">Need More Information?</h3>
<p class="mt-1 text-sm text-gray-600">
For detailed instructions and troubleshooting, refer to the
<a href="/docs/OneDriveSetup.md" target="_blank" class="text-blue-600 hover:underline">OneDrive Setup Documentation</a>.
</p>
</div>
</div>
</div>
<div class="mt-8">
<a href="/status" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Back to Status
</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Elements
const startAuthFlowBtn = document.getElementById('start-auth-flow');
const testTokenBtn = document.getElementById('test-token');
const refreshTokenBtn = document.getElementById('refresh-token-btn');
const tokenStatus = document.getElementById('token-status');
const clientSecretInput = document.getElementById('client-secret');
// Start Authentication Flow button click
startAuthFlowBtn.addEventListener('click', function() {
const clientId = document.getElementById('client-id').value.trim();
const clientSecret = clientSecretInput.value.trim();
const redirectUri = window.location.origin + "/onedrive-callback";
const tenantId = document.getElementById('tenant-id').value.trim() || 'common';
if (!clientId) {
alert('Please enter your Client ID');
return;
}
if (!clientSecret) {
alert('Please enter your Client Secret');
return;
}
// Save client secret to session storage temporarily
sessionStorage.setItem('onedrive_client_secret', clientSecret);
// Generate the authorization URL with .default scope
const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?client_id=${encodeURIComponent(clientId)}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&response_mode=query&scope=${encodeURIComponent('https://graph.microsoft.com/.default offline_access')}&prompt=consent`;
// Redirect the user to the Microsoft login page
window.location.href = authUrl;
});
// Test Token button click
if (testTokenBtn) {
testTokenBtn.addEventListener('click', function() {
testTokenBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Testing...';
testTokenBtn.disabled = true;
fetch('/api/onedrive/test-token')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
alert('Token is valid! Successfully connected to OneDrive.');
} else {
if (data.message && data.message.includes('invalid_grant')) {
if (confirm('Your OneDrive token has expired or is invalid. Would you like to obtain a new token now?')) {
startAuthFlowBtn.click();
}
} else {
alert('Token validation failed: ' + data.message);
}
}
})
.catch(error => {
alert('Error testing token: ' + error.message);
})
.finally(() => {
testTokenBtn.innerHTML = 'Test Token';
testTokenBtn.disabled = false;
});
});
}
// Refresh Token button click
if (refreshTokenBtn) {
refreshTokenBtn.addEventListener('click', function() {
if (confirm('This will start a new authentication flow to obtain a fresh token from Microsoft. Continue?')) {
startAuthFlowBtn.click();
}
});
}
// Copy Environment Variables Button
const copyEnvVarsBtn = document.getElementById('copy-env-vars');
if (copyEnvVarsBtn) {
copyEnvVarsBtn.addEventListener('click', function() {
const envVarsText = document.getElementById('env-vars').textContent;
navigator.clipboard.writeText(envVarsText)
.then(() => {
const originalText = copyEnvVarsBtn.textContent;
copyEnvVarsBtn.textContent = 'Copied!';
copyEnvVarsBtn.classList.add('bg-green-700');
setTimeout(() => {
copyEnvVarsBtn.textContent = originalText;
copyEnvVarsBtn.classList.remove('bg-green-700');
}, 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
alert('Failed to copy text to clipboard');
});
});
}
// Try to retrieve client secret from session storage (if coming back from auth)
if (clientSecretInput && !clientSecretInput.value && sessionStorage.getItem('onedrive_client_secret')) {
clientSecretInput.value = sessionStorage.getItem('onedrive_client_secret');
// Clear it after use
sessionStorage.removeItem('onedrive_client_secret');
}
// If token is not configured but we have a client ID, show the token status section
if (document.getElementById('client-id').value && !tokenStatus.classList.contains('hidden')) {
tokenStatus.classList.remove('hidden');
}
});
</script>
{% endblock %}
+215
View File
@@ -0,0 +1,215 @@
{% extends "base.html" %}
{% block title %}OneDrive Authorization Processing{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="bg-white shadow-md rounded-lg p-6">
<div class="text-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<h2 class="text-2xl font-bold mt-4">Processing Authorization</h2>
<p class="text-gray-600 mt-2">Please wait while we complete the OneDrive authorization process...</p>
</div>
<div class="flex justify-center my-6">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<div id="processing-message" class="text-center text-gray-700">
<p>Exchanging authorization code for refresh token...</p>
</div>
<div id="error-container" class="hidden mt-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Error details:</h3>
<div class="mt-2 text-sm text-red-700" id="error-message">
<!-- Error message will be inserted here -->
</div>
</div>
</div>
</div>
<div class="mt-4">
<a href="/onedrive-setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Return to Setup
</a>
</div>
</div>
<div id="success-container" class="hidden mt-6">
<div class="rounded-md bg-green-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
OneDrive authorization successful! Your refresh token has been saved.
</p>
</div>
</div>
</div>
<div class="mt-6 p-4 bg-gray-100 rounded-md">
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
<p class="text-sm text-gray-600 mb-3">
Copy these environment variables to configure all worker nodes:
</p>
<div class="relative">
<pre id="env-vars" class="bg-gray-800 text-green-400 text-sm p-3 rounded overflow-x-auto"><code></code></pre>
<button id="copy-env-vars" class="absolute top-2 right-2 bg-gray-700 hover:bg-gray-600 text-white text-xs py-1 px-2 rounded">
Copy
</button>
</div>
<p class="text-xs text-gray-500 mt-2">
Add these variables to your .env file or environment configuration.
</p>
</div>
<div class="mt-4 text-center">
<a href="/status" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Go to Status Page
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const code = "{{ code }}";
const clientId = "{{ client_id_value }}";
const redirectUri = window.location.origin + "/onedrive-callback";
const tenantId = "{{ tenant_id }}";
// Automatically exchange the code for a refresh token
if (code) {
exchangeCode(code, clientId, redirectUri, tenantId);
} else {
showError("No authorization code was found in the URL");
}
function exchangeCode(code, clientId, redirectUri, tenantId) {
const formData = new FormData();
formData.append('client_id', clientId);
formData.append('client_secret', "{{ client_secret_value }}"); // Use the pre-configured secret
formData.append('redirect_uri', redirectUri);
formData.append('code', code);
formData.append('tenant_id', tenantId || 'common');
// Show more details in processing message
document.getElementById('processing-message').innerHTML =
'<p>Exchanging authorization code for refresh token...</p>' +
'<p class="text-xs text-gray-500 mt-2">Using tenant: ' + (tenantId || 'common') + '</p>';
fetch('/api/onedrive/exchange-token', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error(err.detail || 'Failed to exchange token');
});
}
return response.json();
})
.then(data => {
if (data.refresh_token) {
// Instead of saving to .env file, update settings in memory
const updateFormData = new FormData();
updateFormData.append('refresh_token', data.refresh_token);
// Use the existing clientId and tenantId if they were provided
if (clientId) updateFormData.append('client_id', clientId);
if (tenantId) updateFormData.append('tenant_id', tenantId);
document.getElementById('processing-message').innerHTML =
'<p>Updating system settings with new token...</p>';
return fetch('/api/onedrive/update-settings', {
method: 'POST',
body: updateFormData
}).then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error('Token received but failed to update settings: ' + (err.detail || 'Unknown error'));
});
}
return response.json();
}).then(() => {
// Show the success message and environment variables
showSuccess(data.refresh_token, clientId, tenantId);
// In 10 seconds, redirect to status page (giving more time to copy)
setTimeout(() => {
window.location.href = '/status';
}, 10000);
});
} else {
throw new Error('No refresh token was received from the server');
}
})
.catch(error => {
showError(error.message);
});
}
function showError(message) {
document.getElementById('processing-message').classList.add('hidden');
document.getElementById('error-container').classList.remove('hidden');
document.getElementById('error-message').innerText = message;
}
function showSuccess(refreshToken, clientId, tenantId) {
document.getElementById('processing-message').classList.add('hidden');
document.getElementById('success-container').classList.remove('hidden');
// Update the environment variables pre block with the new token
const envVarsCode = document.querySelector('#env-vars code');
if (envVarsCode) {
envVarsCode.textContent = `ONEDRIVE_CLIENT_ID=${clientId}
ONEDRIVE_CLIENT_SECRET={{ client_secret_value }}
ONEDRIVE_TENANT_ID=${tenantId || 'common'}
ONEDRIVE_REFRESH_TOKEN=${refreshToken}
ONEDRIVE_FOLDER_PATH=Documents/Uploads`;
}
// Add copy functionality
const copyEnvVarsBtn = document.getElementById('copy-env-vars');
if (copyEnvVarsBtn) {
copyEnvVarsBtn.addEventListener('click', function() {
const envVarsText = document.getElementById('env-vars').textContent;
navigator.clipboard.writeText(envVarsText)
.then(() => {
const originalText = copyEnvVarsBtn.textContent;
copyEnvVarsBtn.textContent = 'Copied!';
copyEnvVarsBtn.classList.add('bg-green-700');
setTimeout(() => {
copyEnvVarsBtn.textContent = originalText;
copyEnvVarsBtn.classList.remove('bg-green-700');
}, 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
alert('Failed to copy text to clipboard');
});
});
}
}
});
</script>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}OneDrive Authorization Error{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="bg-white shadow-md rounded-lg p-6">
<div class="text-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 class="text-2xl font-bold mt-4">Authorization Failed</h2>
<p class="text-gray-600 mt-2">Sorry, we couldn't complete the OneDrive authorization.</p>
</div>
<div class="mb-6">
<div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Error details:</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ error }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="mt-6">
<a href="/onedrive-setup" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Return to Setup
</a>
</div>
</div>
</div>
{% endblock %}
+3 -1
View File
@@ -43,9 +43,11 @@
{% elif provider.icon == "google" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
{% elif provider.icon == "microsoft" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
{% elif provider.icon == "activity" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
{% elif provider.icon == "ftp" %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16l-6-6 6-6M2 10h16m4 0l-6 6m0-12l6 6" />
{% else %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
{% endif %}