diff --git a/.gitignore b/.gitignore index d098eff..6d51044 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ data/scans/ scans/ config/customers.yaml +config/google_drive_credentials.json data/*.json auto_scan_config.json results.xml diff --git a/app.py b/app.py index 278f6df..72d2f17 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ from flask import Flask, request +from datetime import datetime from flask_socketio import SocketIO from flask_cors import CORS import sys @@ -75,8 +76,11 @@ from nmapui.google_drive import ( build_google_drive_auth_status, build_google_drive_auth_url, + create_google_drive_folder, disconnect_google_drive, exchange_google_drive_auth_code, + ensure_google_drive_reports_folder, + save_google_drive_credentials, upload_files_to_google_drive, ) from nmapui.runtime import ( @@ -95,6 +99,7 @@ extract_scan_statistics, find_latest_saved_scan_for_pdf, get_most_recent_scan_xml, + build_artifact_downloads, merge_nmap_xml_files, parse_scan_xml_for_assets, save_scan_metadata, @@ -295,6 +300,85 @@ def safe_emit(event, data=None): ) run_traceroute = traceroute_bindings["run_traceroute"] +def _sanitize_drive_component(value: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("_") + + +def _format_scan_folder_name(metadata: dict, scan_path: str) -> str: + timestamp = metadata.get("scan_start_time") or metadata.get("timestamp") or "" + scan_time = None + if timestamp: + normalized = str(timestamp).replace("Z", "+00:00") + try: + scan_time = datetime.fromisoformat(normalized) + except ValueError: + scan_time = None + if scan_time is None: + scan_time = datetime.now() + time_label = scan_time.strftime("%Y-%m-%d_%H-%M-%S") + target_label = _sanitize_drive_component(str(metadata.get("target") or "")) + customer_label = _sanitize_drive_component(str(metadata.get("customer_name") or "")) + path_label = _sanitize_drive_component(scan_path) + parts = [time_label] + if customer_label: + parts.append(customer_label) + if target_label: + parts.append(target_label) + if path_label: + parts.append(path_label) + return "_".join(parts) + + +def _format_scan_basename(metadata: dict, scan_path: str) -> str: + return _format_scan_folder_name(metadata, scan_path) + + +def _build_drive_upload_names(file_paths, metadata, scan_path) -> dict[str, str]: + base = _format_scan_basename(metadata, scan_path) + downloads = build_artifact_downloads(metadata or {}, customer_fingerprinter=customer_fingerprinter) + names: dict[str, str] = {} + for file_path in file_paths: + name = file_path.name + if name == "scan_report.pdf": + names[str(file_path)] = downloads.get("pdf", f"{base}.pdf") + elif name == "scan.xml": + names[str(file_path)] = downloads.get("xml", f"{base}.xml") + elif name == "scan_web.html": + names[str(file_path)] = downloads.get("html", f"{base}.html") + elif name == "scan_pdf.html": + names[str(file_path)] = f"{base}_pdf.html" + elif name == "scan.nmap": + names[str(file_path)] = f"{base}.nmap" + elif name == "scan.gnmap": + names[str(file_path)] = f"{base}.gnmap" + else: + names[str(file_path)] = f"{base}_{name}" + return names + + +def upload_report_artifacts_to_google_drive(*, scan_path, file_paths, metadata, settings_state): + base_folder_id = str((((settings_state or {}).get("sync") or {}).get("google_drive") or {}).get("folder_id", "") or "").strip() or None + folder_name = _format_scan_folder_name(metadata or {}, scan_path or "") + folder_result = create_google_drive_folder( + name=folder_name, + parent_id=base_folder_id, + credentials_path=GOOGLE_DRIVE_CREDENTIALS_FILE, + token_path=GOOGLE_DRIVE_TOKEN_FILE, + key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, + requests_module=requests, + ) + if not folder_result.get("success"): + return folder_result + file_name_map = _build_drive_upload_names(file_paths, metadata or {}, scan_path or "") + return upload_files_to_google_drive( + credentials_path=GOOGLE_DRIVE_CREDENTIALS_FILE, + token_path=GOOGLE_DRIVE_TOKEN_FILE, + key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, + file_paths=file_paths, + folder_id=folder_result.get("folder_id", ""), + file_name_map=file_name_map, + requests_module=requests, + ) register_app_handlers( app=app, @@ -341,14 +425,7 @@ def safe_emit(event, data=None): startup_state=startup_state, runtime_store=runtime_store, get_auto_scan_thread=runtime_bindings["get_auto_scan_thread"], - upload_report_artifacts_to_google_drive=lambda *, scan_path, file_paths, metadata, settings_state: upload_files_to_google_drive( - credentials_path=GOOGLE_DRIVE_CREDENTIALS_FILE, - token_path=GOOGLE_DRIVE_TOKEN_FILE, - key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, - file_paths=file_paths, - folder_id=str((((settings_state or {}).get("sync") or {}).get("google_drive") or {}).get("folder_id", "") or "").strip(), - requests_module=requests, - ), + upload_report_artifacts_to_google_drive=upload_report_artifacts_to_google_drive, get_customer_fingerprinter=lambda: customer_fingerprinter, get_current_customer=lambda: get_current_customer_state(request.sid), set_current_customer=lambda value: set_current_customer_state( @@ -395,6 +472,16 @@ def safe_emit(event, data=None): state=state, requests_module=requests, ), + ensure_google_drive_reports_folder=lambda: ensure_google_drive_reports_folder( + credentials_path=GOOGLE_DRIVE_CREDENTIALS_FILE, + token_path=GOOGLE_DRIVE_TOKEN_FILE, + key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, + requests_module=requests, + ), + save_google_drive_credentials=lambda credentials: save_google_drive_credentials( + GOOGLE_DRIVE_CREDENTIALS_FILE, + credentials, + ), disconnect_google_drive=lambda: disconnect_google_drive( token_path=GOOGLE_DRIVE_TOKEN_FILE, key_path=GOOGLE_DRIVE_TOKEN_KEY_FILE, @@ -451,6 +538,7 @@ def safe_emit(event, data=None): current_customer=current_customer, extract_scan_statistics=extract_scan_statistics, customer_fingerprinter=customer_fingerprinter, + upload_report_artifacts_to_google_drive=upload_report_artifacts_to_google_drive, runtime_store=runtime_store, find_latest_saved_scan_for_pdf=find_latest_saved_scan_for_pdf, load_json_document=load_json_document, diff --git a/nmapui/app_composition.py b/nmapui/app_composition.py index eb5a2b2..2ceac64 100644 --- a/nmapui/app_composition.py +++ b/nmapui/app_composition.py @@ -126,6 +126,7 @@ def build_report_task_deps( save_scan_metadata, scans_dir, settings_state, + upload_report_artifacts_to_google_drive, socketio_sleep, split_subnet_into_chunks, stylesheet, @@ -148,6 +149,7 @@ def build_report_task_deps( "create_scan_folder": create_scan_folder, "scans_dir": scans_dir, "settings_state": settings_state, + "upload_report_artifacts_to_google_drive": upload_report_artifacts_to_google_drive, "sanitize_customer_dir_name": sanitize_customer_dir_name, "run_nmap_with_xml_output": run_nmap_with_xml_output, "merge_nmap_xml_files": merge_nmap_xml_files, @@ -405,6 +407,8 @@ def build_settings_routes_deps( get_google_drive_auth_status, build_google_drive_auth_url, exchange_google_drive_auth_code, + ensure_google_drive_reports_folder, + save_google_drive_credentials, disconnect_google_drive, ): return { @@ -416,6 +420,8 @@ def build_settings_routes_deps( "get_google_drive_auth_status": get_google_drive_auth_status, "build_google_drive_auth_url": build_google_drive_auth_url, "exchange_google_drive_auth_code": exchange_google_drive_auth_code, + "ensure_google_drive_reports_folder": ensure_google_drive_reports_folder, + "save_google_drive_credentials": save_google_drive_credentials, "disconnect_google_drive": disconnect_google_drive, } diff --git a/nmapui/app_handler_registration.py b/nmapui/app_handler_registration.py index 621ee74..4d3ffbf 100644 --- a/nmapui/app_handler_registration.py +++ b/nmapui/app_handler_registration.py @@ -73,6 +73,8 @@ def register_app_handlers( get_google_drive_auth_status, build_google_drive_auth_url, exchange_google_drive_auth_code, + ensure_google_drive_reports_folder, + save_google_drive_credentials, disconnect_google_drive, logger, ): @@ -188,6 +190,8 @@ def register_app_handlers( get_google_drive_auth_status=get_google_drive_auth_status, build_google_drive_auth_url=build_google_drive_auth_url, exchange_google_drive_auth_code=exchange_google_drive_auth_code, + ensure_google_drive_reports_folder=ensure_google_drive_reports_folder, + save_google_drive_credentials=save_google_drive_credentials, disconnect_google_drive=disconnect_google_drive, ), ) diff --git a/nmapui/app_task_bindings.py b/nmapui/app_task_bindings.py index 28da4d3..d07897c 100644 --- a/nmapui/app_task_bindings.py +++ b/nmapui/app_task_bindings.py @@ -49,6 +49,7 @@ def build_task_bindings( current_customer, extract_scan_statistics, customer_fingerprinter, + upload_report_artifacts_to_google_drive, runtime_store, find_latest_saved_scan_for_pdf, load_json_document, @@ -136,6 +137,7 @@ def generate_report_task(sid, data): create_scan_folder=create_scan_folder, scans_dir=scans_dir, settings_state=settings_state, + upload_report_artifacts_to_google_drive=upload_report_artifacts_to_google_drive, sanitize_customer_dir_name=sanitize_customer_dir_name, run_nmap_with_xml_output=run_nmap_with_xml_output, merge_nmap_xml_files=merge_nmap_xml_files, diff --git a/nmapui/google_drive.py b/nmapui/google_drive.py index 1df2f19..d191c29 100644 --- a/nmapui/google_drive.py +++ b/nmapui/google_drive.py @@ -15,6 +15,27 @@ GOOGLE_DRIVE_REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke" GOOGLE_DRIVE_UPLOAD_ENDPOINT = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,webViewLink" GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file" +GOOGLE_DRIVE_FILES_ENDPOINT = "https://www.googleapis.com/drive/v3/files" +GOOGLE_DRIVE_FOLDER_MIME = "application/vnd.google-apps.folder" + + +def _format_google_drive_error(payload: dict) -> str: + if not isinstance(payload, dict): + return "Unknown error" + error = payload.get("error") + if isinstance(error, dict): + message = error.get("message") or error.get("status") or "Google Drive API error" + reasons = [] + for entry in error.get("errors", []) or []: + if isinstance(entry, dict) and entry.get("reason"): + reasons.append(entry["reason"]) + if reasons: + unique = ", ".join(sorted(set(reasons))) + return f"{message} ({unique})" + return message + if isinstance(error, str): + return error + return payload.get("error_description") or payload.get("message") or "Unknown error" ENCRYPTED_TOKEN_SCHEMA_VERSION = 1 @@ -82,6 +103,14 @@ def load_google_drive_credentials(credentials_path: Path) -> dict: return payload if isinstance(payload, dict) else {} +def save_google_drive_credentials(credentials_path: Path, payload: dict) -> dict: + if not isinstance(payload, dict): + return {"success": False, "error": "Invalid credentials payload"} + _save_json_file(credentials_path, payload) + _set_owner_only_permissions(credentials_path) + return {"success": True, "status": "Google Drive credentials saved"} + + def load_google_drive_token_state(token_path: Path, key_path: Path | None = None) -> dict: key_path = key_path or token_path.with_suffix(".key") try: @@ -316,6 +345,7 @@ def upload_files_to_google_drive( key_path: Path | None = None, file_paths: list[Path], folder_id: str, + file_name_map: dict[str, str] | None = None, requests_module, ) -> dict: access_token = ensure_google_drive_access_token( @@ -326,7 +356,8 @@ def upload_files_to_google_drive( ) uploaded = [] for file_path in file_paths: - metadata = {"name": file_path.name} + override_name = (file_name_map or {}).get(str(file_path)) + metadata = {"name": override_name or file_path.name} if folder_id: metadata["parents"] = [folder_id] with file_path.open("rb") as file_handle: @@ -355,3 +386,100 @@ def upload_files_to_google_drive( "uploaded": uploaded, "status": f"Uploaded {len(uploaded)} file(s) to Google Drive", } + + +def ensure_google_drive_reports_folder( + *, + credentials_path: Path, + token_path: Path, + key_path: Path | None = None, + requests_module, + folder_name: str = "nmapui-reports", +) -> dict: + access_token = ensure_google_drive_access_token( + credentials_path=credentials_path, + token_path=token_path, + key_path=key_path, + requests_module=requests_module, + ) + headers = {"Authorization": f"Bearer {access_token}"} + query = ( + "mimeType='application/vnd.google-apps.folder' " + f"and name='{folder_name}' and trashed=false" + ) + response = requests_module.get( + GOOGLE_DRIVE_FILES_ENDPOINT, + headers=headers, + params={"q": query, "fields": "files(id,name)"}, + timeout=10, + ) + payload = response.json() + if response.status_code >= 400: + error_detail = _format_google_drive_error(payload) + return { + "success": False, + "error": f"Failed to query Drive folder (HTTP {response.status_code}): {error_detail}", + } + + files = payload.get("files") or [] + if files: + folder_id = files[0].get("id") + return {"success": True, "folder_id": folder_id, "status": "Drive folder ready"} + + create_response = requests_module.post( + GOOGLE_DRIVE_FILES_ENDPOINT, + headers={**headers, "Content-Type": "application/json"}, + json={"name": folder_name, "mimeType": GOOGLE_DRIVE_FOLDER_MIME}, + timeout=10, + ) + create_payload = create_response.json() + if create_response.status_code >= 400: + error_detail = _format_google_drive_error(create_payload) + return { + "success": False, + "error": f"Failed to create Drive folder (HTTP {create_response.status_code}): {error_detail}", + } + return { + "success": True, + "folder_id": create_payload.get("id"), + "status": "Drive folder created", + } + + +def create_google_drive_folder( + *, + name: str, + parent_id: str | None, + credentials_path: Path, + token_path: Path, + key_path: Path | None = None, + requests_module, +) -> dict: + access_token = ensure_google_drive_access_token( + credentials_path=credentials_path, + token_path=token_path, + key_path=key_path, + requests_module=requests_module, + ) + headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} + payload = {"name": name, "mimeType": GOOGLE_DRIVE_FOLDER_MIME} + if parent_id: + payload["parents"] = [parent_id] + response = requests_module.post( + GOOGLE_DRIVE_FILES_ENDPOINT, + headers=headers, + json=payload, + timeout=10, + ) + create_payload = response.json() + if response.status_code >= 400: + error_detail = _format_google_drive_error(create_payload) + return { + "success": False, + "error": f"Failed to create Drive folder (HTTP {response.status_code}): {error_detail}", + } + return { + "success": True, + "folder_id": create_payload.get("id"), + "status": "Drive folder created", + } diff --git a/nmapui/handlers/routes.py b/nmapui/handlers/routes.py index 7555263..bf239e1 100644 --- a/nmapui/handlers/routes.py +++ b/nmapui/handlers/routes.py @@ -214,12 +214,22 @@ def runtime_reports(): @app.route("/api/runtime/reports//html") @require_auth def runtime_report_html(scan_path): + artifact = _get_runtime_artifact(runtime_store, scan_path) + download_name = None + if artifact is not None: + download_name = build_artifact_downloads( + dict(artifact.get("payload", {}) or {}), + customer_fingerprinter=customer_fingerprinter, + ).get("html") + wants_download = request.args.get("download") == "1" return _send_runtime_artifact( runtime_store=runtime_store, scans_dir=deps.get("scans_dir"), scan_path=scan_path, artifact_key="html_path", default_name="scan_web.html", + download_name=download_name if wants_download else None, + as_attachment=wants_download, ) @app.route("/api/runtime/reports//pdf") diff --git a/nmapui/handlers/settings.py b/nmapui/handlers/settings.py index 028ca3b..b9095fd 100644 --- a/nmapui/handlers/settings.py +++ b/nmapui/handlers/settings.py @@ -14,6 +14,8 @@ def register_settings_routes(app, deps): get_google_drive_auth_status = deps["get_google_drive_auth_status"] build_google_drive_auth_url = deps["build_google_drive_auth_url"] exchange_google_drive_auth_code = deps["exchange_google_drive_auth_code"] + ensure_google_drive_reports_folder = deps["ensure_google_drive_reports_folder"] + save_google_drive_credentials = deps["save_google_drive_credentials"] disconnect_google_drive = deps["disconnect_google_drive"] @app.route("/api/settings") @@ -94,6 +96,16 @@ def google_drive_auth_url_route(): status_code = 200 if result.get("success") else 400 return jsonify(result), status_code + @app.route("/api/settings/google-drive/credentials", methods=["POST"]) + @require_auth + def google_drive_credentials_route(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict) or "credentials" not in payload: + return jsonify({"success": False, "error": "Invalid credentials payload"}), 400 + result = save_google_drive_credentials(payload.get("credentials")) + status_code = 200 if result.get("success") else 400 + return jsonify(result), status_code + @app.route("/api/settings/google-drive/callback") def google_drive_callback_route(): code = request.args.get("code", "") @@ -112,10 +124,38 @@ def google_drive_callback_route(): f"

{result.get('error', 'Unknown error')}

", 400, ) - return ( - "

Google Drive connected

" - "

You can close this window and return to NmapUI.

" - ) + folder_result = ensure_google_drive_reports_folder() + normalized = normalize_settings_document(settings_state) + google_drive_state = normalized.get("sync", {}).get("google_drive", {}) + if folder_result.get("success"): + google_drive_state.update( + { + "enabled": True, + "folder_id": folder_result.get("folder_id", ""), + "status": "Connected", + } + ) + else: + failure_reason = folder_result.get("error", "Folder setup failed") + google_drive_state.update( + { + "enabled": False, + "status": f"Connected (folder setup failed: {failure_reason})", + } + ) + normalized["sync"]["google_drive"] = google_drive_state + normalized = save_settings(normalized) + settings_state.clear() + settings_state.update(normalized) + message = "Google Drive connected." + if folder_result.get("success"): + message = "Google Drive connected. Reports will sync to nmapui-reports." + else: + message = ( + "Google Drive connected, but the default folder could not be created. " + f"{folder_result.get('error', 'Check Settings to finish setup.')}" + ) + return f"

{message}

You can close this window and return to NmapUI.

" @app.route("/api/settings/google-drive/disconnect", methods=["POST"]) @require_auth diff --git a/nmapui/reporting.py b/nmapui/reporting.py index 7822417..7edf41c 100644 --- a/nmapui/reporting.py +++ b/nmapui/reporting.py @@ -753,6 +753,7 @@ def build_artifact_downloads(metadata, *, customer_fingerprinter=None): safe_cust = re.sub(r"[^\w\-]", "_", customer) safe_target = re.sub(r"[^\w\.]", "_", target) return { + "html": f"Nmap_Report_{safe_cust}_{safe_target}_{date_str}_{time_str}.html", "pdf": f"Nmap_Audit_{safe_cust}_{safe_target}_{date_str}_{time_str}.pdf", "xml": f"Nmap_Raw_{safe_cust}_{safe_target}_{date_str}_{time_str}.xml", } diff --git a/nmapui/workflow_context.py b/nmapui/workflow_context.py index fe83084..4edb5a2 100644 --- a/nmapui/workflow_context.py +++ b/nmapui/workflow_context.py @@ -66,6 +66,7 @@ class ReportWorkflowContext: customer_fingerprinter: Any runtime_store: Any = None settings_state: Any = None + upload_report_artifacts_to_google_drive: Any = None web_stylesheet: Any = None pdf_stylesheet: Any = None on_job_end: Any = None @@ -181,6 +182,7 @@ def build_report_workflow_context(deps): customer_fingerprinter=deps["customer_fingerprinter"], runtime_store=deps.get("runtime_store"), settings_state=deps.get("settings_state"), + upload_report_artifacts_to_google_drive=deps.get("upload_report_artifacts_to_google_drive"), web_stylesheet=deps.get("web_stylesheet"), pdf_stylesheet=deps.get("pdf_stylesheet"), on_job_end=deps.get("on_job_end"), diff --git a/static/google_drive_icon.svg b/static/google_drive_icon.svg new file mode 100644 index 0000000..a8cefd5 --- /dev/null +++ b/static/google_drive_icon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/static/js/reports_tab.js b/static/js/reports_tab.js index d4cf4e1..1ac767b 100644 --- a/static/js/reports_tab.js +++ b/static/js/reports_tab.js @@ -273,6 +273,7 @@ function renderHistoryContextPanel(scans) { actions.className = 'mt-4 flex flex-wrap gap-2'; if (latestScan.has_html) { actions.appendChild(createScanActionLink(buildRuntimeReportArtifactUrl(latestScan.path, 'html'), 'View HTML Report', true)); + actions.appendChild(createScanActionLink(`${buildRuntimeReportArtifactUrl(latestScan.path, 'html')}?download=1`, 'Download HTML')); } if (latestScan.has_pdf) { actions.appendChild(createScanActionLink(buildRuntimeReportArtifactUrl(latestScan.path, 'pdf'), 'Download PDF')); @@ -471,6 +472,7 @@ function createHistoryCard(scan, options = {}) { if (scan.has_html) { actions.appendChild(createScanActionLink(buildRuntimeReportArtifactUrl(scan.path, 'html'), 'View Report', true)); + actions.appendChild(createScanActionLink(`${buildRuntimeReportArtifactUrl(scan.path, 'html')}?download=1`, 'Download HTML')); } if (scan.has_pdf) { actions.appendChild(createScanActionLink(buildRuntimeReportArtifactUrl(scan.path, 'pdf'), 'Download PDF')); diff --git a/static/js/settings_tab.js b/static/js/settings_tab.js index 8ef6e2a..f60cc2e 100644 --- a/static/js/settings_tab.js +++ b/static/js/settings_tab.js @@ -355,6 +355,9 @@ async function loadSettingsTab(force = false) { async function connectGoogleDrive() { setSyncStatus('settings-google-drive-status', 'Starting Google Drive authorization...'); try { + if (!googleDriveAuthStatus?.configured) { + throw new Error('Google Drive credentials are not bundled in this build. Please contact support.'); + } const response = await fetch('/api/settings/google-drive/auth-url'); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload.auth_url) { @@ -362,12 +365,25 @@ async function connectGoogleDrive() { } window.open(payload.auth_url, '_blank', 'noopener,noreferrer'); setSyncStatus('settings-google-drive-status', 'Google Drive authorization opened in a new tab.'); + waitForGoogleDriveConnection(); } catch (error) { console.error('Error starting Google Drive auth:', error); setSyncStatus('settings-google-drive-status', error.message || 'Failed to start Google Drive auth.', true); } } +async function waitForGoogleDriveConnection(timeoutMs = 90000, intervalMs = 4000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + await loadGoogleDriveAuthStatus(); + if (googleDriveAuthStatus?.connected) { + await loadSettingsTab(true); + return; + } + } +} + async function disconnectGoogleDriveAccount() { setSyncStatus('settings-google-drive-status', 'Disconnecting Google Drive...'); try { @@ -383,6 +399,31 @@ async function disconnectGoogleDriveAccount() { } } +async function uploadGoogleDriveCredentials(file) { + if (!file) { + return; + } + setSyncStatus('settings-google-drive-status', 'Uploading Google Drive credentials...'); + try { + const text = await file.text(); + const credentials = JSON.parse(text); + const response = await fetch('/api/settings/google-drive/credentials', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentials }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.success !== true) { + throw new Error(payload.error || `Failed to save credentials (${response.status})`); + } + setSyncStatus('settings-google-drive-status', payload.status || 'Credentials saved', false); + await loadGoogleDriveAuthStatus(); + } catch (error) { + console.error('Error uploading Google Drive credentials:', error); + setSyncStatus('settings-google-drive-status', error.message || 'Failed to upload credentials.', true); + } +} + async function saveSettingsTab() { const nextState = getSettingsFormState(); diff --git a/templates/index.html b/templates/index.html index 605fc32..2253931 100644 --- a/templates/index.html +++ b/templates/index.html @@ -854,6 +854,16 @@

Sync Configuration

@@ -864,6 +874,7 @@

Sync Configuration

+

Credentials are managed by NmapUI. Click Connect to authorize.

Not configured