diff --git a/application/single_app/app.py b/application/single_app/app.py index 54336100..3d06c7dc 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -58,6 +58,7 @@ from route_backend_public_workspaces import * from route_backend_public_documents import * from route_backend_public_prompts import * +from route_backend_user_agreement import register_route_backend_user_agreement from route_backend_speech import register_route_backend_speech from route_backend_tts import register_route_backend_tts from route_enhanced_citations import register_enhanced_citations_routes @@ -617,6 +618,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Public Prompts Routes ---------- register_route_backend_public_prompts(app) +# ------------------- API User Agreement Routes ---------- +register_route_backend_user_agreement(app) + # ------------------- Extenral Health Routes ---------- register_route_external_health(app) diff --git a/application/single_app/config.py b/application/single_app/config.py index 2224a49e..03f34724 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.235.025" +VERSION = "0.236.007" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index fb005f06..9870eca9 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -1080,3 +1080,138 @@ def log_public_workspace_status_change( level=logging.ERROR ) debug_print(f"⚠️ Warning: Failed to log public workspace status change: {str(e)}") + + +def log_user_agreement_accepted( + user_id: str, + workspace_type: str, + workspace_id: str, + workspace_name: Optional[str] = None, + action_context: Optional[str] = None +) -> None: + """ + Log when a user accepts a user agreement in a workspace. + This record is used to track acceptance and support daily acceptance features. + + Args: + user_id (str): The ID of the user who accepted the agreement + workspace_type (str): Type of workspace ('personal', 'group', 'public') + workspace_id (str): The ID of the workspace + workspace_name (str, optional): The name of the workspace + action_context (str, optional): The context/action that triggered the agreement + (e.g., 'file_upload', 'chat') + """ + + try: + import uuid + + # Create user agreement acceptance record + acceptance_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'user_agreement_accepted', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'accepted_date': datetime.utcnow().strftime('%Y-%m-%d'), # Date only for daily lookup + 'workspace_type': workspace_type, + 'workspace_context': { + f'{workspace_type}_workspace_id': workspace_id, + 'workspace_name': workspace_name + }, + 'action_context': action_context + } + + # Save to activity_logs container + cosmos_activity_logs_container.create_item(body=acceptance_record) + + # Also log to Application Insights for monitoring + log_event( + message=f"User agreement accepted: user {user_id} in {workspace_type} workspace {workspace_id}", + extra=acceptance_record, + level=logging.INFO + ) + + debug_print(f"✅ Logged user agreement acceptance: user {user_id} in {workspace_type} workspace {workspace_id}") + + except Exception as e: + # Log error but don't fail the operation + log_event( + message=f"Error logging user agreement acceptance: {str(e)}", + extra={ + 'user_id': user_id, + 'workspace_type': workspace_type, + 'workspace_id': workspace_id, + 'error': str(e) + }, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log user agreement acceptance: {str(e)}") + + +def has_user_accepted_agreement_today( + user_id: str, + workspace_type: str, + workspace_id: str +) -> bool: + """ + Check if a user has already accepted the user agreement today for a given workspace. + Used to implement the "accept once per day" feature. + + Args: + user_id (str): The ID of the user + workspace_type (str): Type of workspace ('personal', 'group', 'public') + workspace_id (str): The ID of the workspace + + Returns: + bool: True if user has accepted today, False otherwise + """ + + try: + today_date = datetime.utcnow().strftime('%Y-%m-%d') + + # Query for today's acceptance record + query = """ + SELECT VALUE COUNT(1) FROM c + WHERE c.user_id = @user_id + AND c.activity_type = 'user_agreement_accepted' + AND c.accepted_date = @today_date + AND c.workspace_type = @workspace_type + AND c.workspace_context[@workspace_id_key] = @workspace_id + """ + + workspace_id_key = f'{workspace_type}_workspace_id' + + params = [ + {"name": "@user_id", "value": user_id}, + {"name": "@today_date", "value": today_date}, + {"name": "@workspace_type", "value": workspace_type}, + {"name": "@workspace_id_key", "value": workspace_id_key}, + {"name": "@workspace_id", "value": workspace_id} + ] + + results = list(cosmos_activity_logs_container.query_items( + query=query, + parameters=params, + enable_cross_partition_query=False # Query by partition key (user_id) + )) + + count = results[0] if results else 0 + + debug_print(f"🔍 User agreement check: user {user_id}, workspace {workspace_id}, today={today_date}, accepted={count > 0}") + + return count > 0 + + except Exception as e: + # Log error and return False (require re-acceptance on error) + log_event( + message=f"Error checking user agreement acceptance: {str(e)}", + extra={ + 'user_id': user_id, + 'workspace_type': workspace_type, + 'workspace_id': workspace_id, + 'error': str(e) + }, + level=logging.ERROR + ) + debug_print(f"⚠️ Error checking user agreement acceptance: {str(e)}") + return False diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 27c30e9c..7b2fdc5f 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -2716,7 +2716,7 @@ def generate(): credential = DefaultAzureCredential() token_provider = get_bearer_token_provider( credential, - "https://cognitiveservices.azure.com/.default" + cognitive_services_scope ) gpt_client = AzureOpenAI( api_version=api_version, diff --git a/application/single_app/route_backend_user_agreement.py b/application/single_app/route_backend_user_agreement.py new file mode 100644 index 00000000..f46559ff --- /dev/null +++ b/application/single_app/route_backend_user_agreement.py @@ -0,0 +1,167 @@ +# route_backend_user_agreement.py + +from config import * +from functions_authentication import * +from functions_settings import get_settings +from functions_public_workspaces import find_public_workspace_by_id +from functions_activity_logging import log_user_agreement_accepted, has_user_accepted_agreement_today +from swagger_wrapper import swagger_route, get_auth_security +from functions_debug import debug_print + + +def register_route_backend_user_agreement(app): + """ + Register user agreement API endpoints under '/api/user_agreement/...' + These endpoints handle checking and recording user agreement acceptance. + """ + + @app.route("/api/user_agreement/check", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_check_user_agreement(): + """ + GET /api/user_agreement/check + Check if the current user needs to accept a user agreement for a workspace. + + Query params: + workspace_id: The workspace ID + workspace_type: The workspace type ('personal', 'group', 'public', 'chat') + action_context: The action context ('file_upload', 'chat') - optional + + Returns: + { + needsAgreement: bool, + agreementText: str (if needs agreement), + enableDailyAcceptance: bool + } + """ + info = get_current_user_info() + user_id = info["userId"] + + workspace_id = request.args.get("workspace_id") + workspace_type = request.args.get("workspace_type") + action_context = request.args.get("action_context", "file_upload") + + if not workspace_id or not workspace_type: + return jsonify({"error": "workspace_id and workspace_type are required"}), 400 + + # Validate workspace type + valid_types = ["personal", "group", "public", "chat"] + if workspace_type not in valid_types: + return jsonify({"error": f"Invalid workspace_type. Must be one of: {', '.join(valid_types)}"}), 400 + + # Get global user agreement settings from app settings + settings = get_settings() + + # Check if user agreement is enabled globally + if not settings.get("enable_user_agreement", False): + return jsonify({ + "needsAgreement": False, + "agreementText": "", + "enableDailyAcceptance": False + }), 200 + + apply_to = settings.get("user_agreement_apply_to", []) + + # Check if the agreement applies to this workspace type or action + applies = False + if workspace_type in apply_to: + applies = True + elif action_context == "chat" and "chat" in apply_to: + applies = True + + if not applies: + return jsonify({ + "needsAgreement": False, + "agreementText": "", + "enableDailyAcceptance": False + }), 200 + + # Check if daily acceptance is enabled and user already accepted today + enable_daily_acceptance = settings.get("enable_user_agreement_daily", False) + + if enable_daily_acceptance: + already_accepted = has_user_accepted_agreement_today(user_id, workspace_type, workspace_id) + if already_accepted: + debug_print(f"[USER_AGREEMENT] User {user_id} already accepted today for {workspace_type} workspace {workspace_id}") + return jsonify({ + "needsAgreement": False, + "agreementText": "", + "enableDailyAcceptance": True, + "alreadyAcceptedToday": True + }), 200 + + # User needs to accept the agreement + return jsonify({ + "needsAgreement": True, + "agreementText": settings.get("user_agreement_text", ""), + "enableDailyAcceptance": enable_daily_acceptance + }), 200 + + @app.route("/api/user_agreement/accept", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_accept_user_agreement(): + """ + POST /api/user_agreement/accept + Record that a user has accepted the user agreement for a workspace. + + Body JSON: + { + workspace_id: str, + workspace_type: str ('personal', 'group', 'public'), + action_context: str (optional, e.g., 'file_upload', 'chat') + } + + Returns: + { success: bool, message: str } + """ + info = get_current_user_info() + user_id = info["userId"] + + data = request.get_json() or {} + workspace_id = data.get("workspace_id") + workspace_type = data.get("workspace_type") + action_context = data.get("action_context", "file_upload") + + if not workspace_id or not workspace_type: + return jsonify({"error": "workspace_id and workspace_type are required"}), 400 + + # Validate workspace type + valid_types = ["personal", "group", "public"] + if workspace_type not in valid_types: + return jsonify({"error": f"Invalid workspace_type. Must be one of: {', '.join(valid_types)}"}), 400 + + # Get workspace name for logging + workspace_name = None + if workspace_type == "public": + ws = find_public_workspace_by_id(workspace_id) + if ws: + workspace_name = ws.get("name", "") + + # Log the acceptance + try: + log_user_agreement_accepted( + user_id=user_id, + workspace_type=workspace_type, + workspace_id=workspace_id, + workspace_name=workspace_name, + action_context=action_context + ) + + debug_print(f"[USER_AGREEMENT] Recorded acceptance: user {user_id}, {workspace_type} workspace {workspace_id}") + + return jsonify({ + "success": True, + "message": "User agreement acceptance recorded" + }), 200 + + except Exception as e: + debug_print(f"[USER_AGREEMENT] Error recording acceptance: {str(e)}") + log_event(f"Error recording user agreement acceptance: {str(e)}", level=logging.ERROR) + return jsonify({ + "success": False, + "error": f"Failed to record acceptance: {str(e)}" + }), 500 diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index da45c965..66c6a45a 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -135,6 +135,9 @@ def admin_settings(): 'name': 'default_agent', 'is_global': True } + log_event("Error retrieving global agents for default selection.", level=logging.ERROR) + debug_print("Error retrieving global agents for default selection.") + if 'allow_user_agents' not in settings: settings['allow_user_agents'] = False if 'allow_user_custom_agent_endpoints' not in settings: @@ -158,6 +161,16 @@ def admin_settings(): if 'classification_banner_text_color' not in settings: settings['classification_banner_text_color'] = '#ffffff' # White text by default + # --- Add defaults for user agreement --- + if 'enable_user_agreement' not in settings: + settings['enable_user_agreement'] = False + if 'user_agreement_text' not in settings: + settings['user_agreement_text'] = '' + if 'user_agreement_apply_to' not in settings: + settings['user_agreement_apply_to'] = [] + if 'enable_user_agreement_daily' not in settings: + settings['enable_user_agreement_daily'] = False + # --- Add defaults for key vault if 'enable_key_vault_secret_storage' not in settings: settings['enable_key_vault_secret_storage'] = False @@ -190,7 +203,7 @@ def admin_settings(): pass # Replace with actual logic except Exception as e: print(f"Error retrieving GPT deployments: {e}") - # ... similar try/except for embedding and image models ... + log_event(f"Error retrieving GPT deployments: {e}", level=logging.ERROR) # Check for application updates current_version = app.config['VERSION'] @@ -233,6 +246,7 @@ def admin_settings(): settings.update(new_settings) except Exception as e: print(f"Error checking for updates: {e}") + log_event(f"Error checking for updates: {e}", level=logging.ERROR) # Get the persisted values for template rendering update_available = settings.get('update_available', False) @@ -367,19 +381,22 @@ def admin_settings(): except Exception as e: print(f"Error parsing gpt_model_json: {e}") flash('Error parsing GPT model data. Changes may not be saved.', 'warning') + log_event(f"Error parsing GPT model data: {e}", level=logging.ERROR) gpt_model_obj = settings.get('gpt_model', {'selected': [], 'all': []}) # Fallback - # ... similar try/except for embedding and image models ... + try: embedding_model_obj = json.loads(embedding_model_json) if embedding_model_json else {'selected': [], 'all': []} except Exception as e: print(f"Error parsing embedding_model_json: {e}") flash('Error parsing Embedding model data. Changes may not be saved.', 'warning') + log_event(f"Error parsing Embedding model data: {e}", level=logging.ERROR) embedding_model_obj = settings.get('embedding_model', {'selected': [], 'all': []}) # Fallback try: image_gen_model_obj = json.loads(image_gen_model_json) if image_gen_model_json else {'selected': [], 'all': []} except Exception as e: print(f"Error parsing image_gen_model_json: {e}") flash('Error parsing Image Gen model data. Changes may not be saved.', 'warning') + log_event(f"Error parsing Image Gen model data: {e}", level=logging.ERROR) image_gen_model_obj = settings.get('image_gen_model', {'selected': [], 'all': []}) # Fallback # --- Extract banner fields from form_data --- @@ -537,6 +554,28 @@ def admin_settings(): retention_policy_next_run = next_run.isoformat() + # --- User Agreement Settings --- + enable_user_agreement = form_data.get('enable_user_agreement') == 'on' + user_agreement_text = form_data.get('user_agreement_text', '').strip() + enable_user_agreement_daily = form_data.get('enable_user_agreement_daily') == 'on' + + # Build apply_to list from checkboxes + user_agreement_apply_to = [] + if form_data.get('user_agreement_apply_personal') == 'on': + user_agreement_apply_to.append('personal') + if form_data.get('user_agreement_apply_group') == 'on': + user_agreement_apply_to.append('group') + if form_data.get('user_agreement_apply_public') == 'on': + user_agreement_apply_to.append('public') + if form_data.get('user_agreement_apply_chat') == 'on': + user_agreement_apply_to.append('chat') + + # Validate word count (max 200 words) + if enable_user_agreement and user_agreement_text: + word_count = len(user_agreement_text.split()) + if word_count > 200: + flash('User Agreement text exceeds 200 word limit. Please shorten the text.', 'warning') + # --- Authentication & Redirect Settings --- enable_front_door = form_data.get('enable_front_door') == 'on' front_door_url = form_data.get('front_door_url', '').strip() @@ -658,6 +697,12 @@ def is_valid_url(url): 'retention_policy_execution_hour': retention_policy_execution_hour, 'retention_policy_next_run': retention_policy_next_run, + # User Agreement + 'enable_user_agreement': enable_user_agreement, + 'user_agreement_text': user_agreement_text, + 'user_agreement_apply_to': user_agreement_apply_to, + 'enable_user_agreement_daily': enable_user_agreement_daily, + # Multimedia & Metadata 'enable_video_file_support': enable_video_file_support, 'enable_audio_file_support': enable_audio_file_support, @@ -866,7 +911,7 @@ def is_valid_url(url): except Exception as e: print(f"Error processing logo file: {e}") # Log the error for debugging flash(f"Error processing logo file: {e}. Existing logo preserved.", "danger") - # On error, new_settings['custom_logo_base64'] keeps its initial value (the old logo) + log_event(f"Error processing logo file: {e}", level=logging.ERROR) # Process dark mode logo file upload logo_dark_file = request.files.get('logo_dark_file') @@ -949,7 +994,7 @@ def is_valid_url(url): except Exception as e: print(f"Error processing dark mode logo file: {e}") # Log the error for debugging flash(f"Error processing dark mode logo file: {e}. Existing dark mode logo preserved.", "danger") - # On error, new_settings['custom_logo_dark_base64'] keeps its initial value (the old logo) + log_event(f"Error processing dark mode logo file: {e}", level=logging.ERROR) # Process favicon file upload favicon_file = request.files.get('favicon_file') @@ -1023,7 +1068,7 @@ def is_valid_url(url): except Exception as e: print(f"Error processing favicon file: {e}") # Log the error for debugging flash(f"Error processing favicon file: {e}. Existing favicon preserved.", "danger") - # On error, new_settings['custom_favicon_base64'] keeps its initial value (the old favicon) + log_event(f"Error processing favicon file: {e}", level=logging.ERROR) # --- Update settings in DB --- # new_settings now contains either the new logo/favicon base64 or the original ones diff --git a/application/single_app/semantic_kernel_plugins/smart_http_plugin.py b/application/single_app/semantic_kernel_plugins/smart_http_plugin.py index f5209685..2292e7bc 100644 --- a/application/single_app/semantic_kernel_plugins/smart_http_plugin.py +++ b/application/single_app/semantic_kernel_plugins/smart_http_plugin.py @@ -560,6 +560,7 @@ async def _summarize_large_content(self, content: str, uri: str, page_count: int from functions_settings import get_settings from openai import AzureOpenAI from azure.identity import DefaultAzureCredential, get_bearer_token_provider + from config import cognitive_services_scope settings = get_settings() @@ -580,7 +581,6 @@ async def _summarize_large_content(self, content: str, uri: str, page_count: int ) else: if settings.get('azure_openai_gpt_authentication_type') == 'managed_identity': - cognitive_services_scope = "https://cognitiveservices.azure.com/.default" token_provider = get_bearer_token_provider( DefaultAzureCredential(), cognitive_services_scope diff --git a/application/single_app/static/js/chat/chat-input-actions.js b/application/single_app/static/js/chat/chat-input-actions.js index 0325812f..4ef2fab4 100644 --- a/application/single_app/static/js/chat/chat-input-actions.js +++ b/application/single_app/static/js/chat/chat-input-actions.js @@ -374,13 +374,29 @@ if (fileInputEl) { // Hide the upload button since we're auto-uploading uploadBtn.style.display = "none"; - // Automatically upload the file - if (!currentConversationId) { - createNewConversation(() => { + // Check for user agreement before uploading + const doUpload = () => { + if (!currentConversationId) { + createNewConversation(() => { + uploadFileToConversation(file); + }); + } else { uploadFileToConversation(file); - }); + } + }; + + // Check if UserAgreementManager exists and check for agreement + if (window.UserAgreementManager) { + window.UserAgreementManager.checkBeforeUpload( + fileInputEl.files, + 'chat', + 'default', + function(files) { + doUpload(); + } + ); } else { - uploadFileToConversation(file); + doUpload(); } } else { resetFileButton(); @@ -407,12 +423,29 @@ if (uploadBtn) { return; } - if (!currentConversationId) { - createNewConversation(() => { + // Check for user agreement before uploading + const doUpload = () => { + if (!currentConversationId) { + createNewConversation(() => { + uploadFileToConversation(file); + }); + } else { uploadFileToConversation(file); - }); + } + }; + + // Check if UserAgreementManager exists and check for agreement + if (window.UserAgreementManager) { + window.UserAgreementManager.checkBeforeUpload( + fileInput.files, + 'chat', + 'default', + function(files) { + doUpload(); + } + ); } else { - uploadFileToConversation(file); + doUpload(); } }); } diff --git a/application/single_app/static/js/group/manage_group.js b/application/single_app/static/js/group/manage_group.js index 87a12b58..eef65d03 100644 --- a/application/single_app/static/js/group/manage_group.js +++ b/application/single_app/static/js/group/manage_group.js @@ -555,24 +555,45 @@ function rejectRequest(requestId) { }); } +// Search users for manual add // Search users for manual add function searchUsers() { const term = $("#userSearchTerm").val().trim(); if (!term) { - alert("Enter a name or email to search."); + // Show inline validation error + $("#searchStatus").text("⚠️ Please enter a name or email to search"); + $("#searchStatus").removeClass("text-muted text-success").addClass("text-warning"); + $("#userSearchTerm").addClass("is-invalid"); return; } + + // Clear any previous validation states + $("#userSearchTerm").removeClass("is-invalid"); + $("#searchStatus").removeClass("text-warning text-danger text-success").addClass("text-muted"); $("#searchStatus").text("Searching..."); $("#searchUsersBtn").prop("disabled", true); $.get("/api/userSearch", { query: term }) - .done(renderUserSearchResults) + .done(function(users) { + renderUserSearchResults(users); + // Show success status + if (users && users.length > 0) { + $("#searchStatus").text(`✓ Found ${users.length} user(s)`); + $("#searchStatus").removeClass("text-muted text-warning text-danger").addClass("text-success"); + } else { + $("#searchStatus").text("No users found"); + $("#searchStatus").removeClass("text-muted text-warning text-success").addClass("text-muted"); + } + }) .fail(function (jq) { const err = jq.responseJSON?.error || jq.statusText; - alert("User search failed: " + err); + // Show inline error + $("#searchStatus").text(`❌ Search failed: ${err}`); + $("#searchStatus").removeClass("text-muted text-warning text-success").addClass("text-danger"); + // Also show toast for critical errors + showToast("User search failed: " + err, "danger"); }) .always(function () { - $("#searchStatus").text(""); $("#searchUsersBtn").prop("disabled", false); }); } diff --git a/application/single_app/static/js/public/manage_public_workspace.js b/application/single_app/static/js/public/manage_public_workspace.js index ba1f5b09..3b31ce9b 100644 --- a/application/single_app/static/js/public/manage_public_workspace.js +++ b/application/single_app/static/js/public/manage_public_workspace.js @@ -1292,3 +1292,4 @@ async function bulkRemoveMembers() { // Reload members and clear selection loadMembers(); } + diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js index 751920db..f48c096d 100644 --- a/application/single_app/static/js/public/public_workspace.js +++ b/application/single_app/static/js/public/public_workspace.js @@ -75,15 +75,15 @@ document.addEventListener('DOMContentLoaded', ()=>{ if (btnChangePublic) btnChangePublic.onclick = onChangeActivePublic; // Upload functionality - handle both button click and drag-and-drop - if (uploadBtn) uploadBtn.onclick = onPublicUploadClick; + if (uploadBtn) uploadBtn.onclick = () => checkUserAgreementBeforePublicUpload(); // Add upload area functionality (drag-and-drop and click-to-browse) const uploadArea = document.getElementById('upload-area'); if (fileInput && uploadArea) { - // Auto-upload on file selection + // Auto-upload on file selection (with user agreement check) fileInput.addEventListener('change', () => { if (fileInput.files && fileInput.files.length > 0) { - onPublicUploadClick(); + checkUserAgreementBeforePublicUpload(); } }); @@ -113,9 +113,9 @@ document.addEventListener('DOMContentLoaded', ()=>{ uploadArea.classList.remove('dragover'); uploadArea.style.borderColor = ''; if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { - // Set the files to the file input and trigger upload + // Set the files to the file input and trigger upload with user agreement check fileInput.files = e.dataTransfer.files; - onPublicUploadClick(); + checkUserAgreementBeforePublicUpload(); } }); } @@ -454,6 +454,32 @@ function renderPublicDocsPagination(page, pageSize, totalCount){ ul.append(make(page-1,'«',page<=1,false)); let start=1,end=totalPages; if(totalPages>5){ const mid=2; if(page>mid) start=page-mid; end=start+4; if(end>totalPages){ end=totalPages; start=end-4; } } if(start>1){ ul.append(make(1,'1',false,false)); ul.append(make(0,'...',true,false)); } for(let p=start;p<=end;p++) ul.append(make(p,p,false,p===page)); if(end=totalPages,false)); container.append(ul); } +/** + * Check for user agreement before public workspace upload + * Wraps onPublicUploadClick with user agreement check + */ +function checkUserAgreementBeforePublicUpload() { + if (!fileInput || !fileInput.files || fileInput.files.length === 0) { + alert('Select files'); + return; + } + + // Check for user agreement before uploading + if (window.UserAgreementManager && activePublicId) { + window.UserAgreementManager.checkBeforeUpload( + fileInput.files, + 'public', + activePublicId, + function(files) { + // Proceed with upload + onPublicUploadClick(); + } + ); + } else { + onPublicUploadClick(); + } +} + async function onPublicUploadClick() { if (!fileInput) return alert('File input not found'); const files = fileInput.files; diff --git a/application/single_app/static/js/user-agreement.js b/application/single_app/static/js/user-agreement.js new file mode 100644 index 00000000..9cff525d --- /dev/null +++ b/application/single_app/static/js/user-agreement.js @@ -0,0 +1,244 @@ +// user-agreement.js +// Shared module for User Agreement prompts before file uploads + +/** + * User Agreement Manager + * Handles checking and prompting for user agreement acceptance before file uploads + */ +window.UserAgreementManager = (function() { + 'use strict'; + + let modal = null; + let pendingCallback = null; + let pendingFiles = null; + + /** + * Initialize the User Agreement Manager + * Sets up event listeners for the modal + */ + function init() { + // Get modal element + const modalEl = document.getElementById('userAgreementUploadModal'); + if (!modalEl) { + console.warn('[UserAgreement] Modal element not found'); + return; + } + + modal = new bootstrap.Modal(modalEl); + + // Accept button handler + const acceptBtn = document.getElementById('userAgreementUploadAcceptBtn'); + if (acceptBtn) { + acceptBtn.addEventListener('click', function() { + onAccept(); + }); + } + + // Cancel button handler - clear pending state + const cancelBtn = document.getElementById('userAgreementUploadCancelBtn'); + if (cancelBtn) { + cancelBtn.addEventListener('click', function() { + onCancel(); + }); + } + + // Modal close handler (X button or backdrop click) + modalEl.addEventListener('hidden.bs.modal', function() { + // If modal was closed without accepting, treat as cancel + if (pendingCallback) { + pendingCallback = null; + pendingFiles = null; + } + }); + + console.log('[UserAgreement] Manager initialized'); + } + + /** + * Check if user agreement is required for a workspace type + * @param {string} workspaceType - 'personal', 'group', 'public', or 'chat' + * @param {string} workspaceId - The workspace ID (can be empty for personal/chat) + * @returns {Promise} - { needsAgreement, agreementText, enableDailyAcceptance } + */ + async function checkAgreement(workspaceType, workspaceId) { + try { + const params = new URLSearchParams({ + workspace_type: workspaceType, + workspace_id: workspaceId || 'default', + action_context: 'file_upload' + }); + + const response = await fetch(`/api/user_agreement/check?${params.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + console.warn('[UserAgreement] Check failed:', response.status); + return { needsAgreement: false }; + } + + return await response.json(); + } catch (error) { + console.error('[UserAgreement] Error checking agreement:', error); + return { needsAgreement: false }; + } + } + + /** + * Record that user accepted the agreement + * @param {string} workspaceType - 'personal', 'group', 'public', or 'chat' + * @param {string} workspaceId - The workspace ID + * @returns {Promise} - Success status + */ + async function recordAcceptance(workspaceType, workspaceId) { + try { + const response = await fetch('/api/user_agreement/accept', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + workspace_type: workspaceType, + workspace_id: workspaceId || 'default', + action_context: 'file_upload' + }) + }); + + if (!response.ok) { + console.warn('[UserAgreement] Accept failed:', response.status); + return false; + } + + const data = await response.json(); + return data.success === true; + } catch (error) { + console.error('[UserAgreement] Error recording acceptance:', error); + return false; + } + } + + /** + * Show the user agreement modal + * @param {string} agreementText - The agreement text (markdown) + * @param {boolean} enableDailyAcceptance - Whether daily acceptance is enabled + */ + function showModal(agreementText, enableDailyAcceptance) { + const contentDiv = document.getElementById('userAgreementUploadContent'); + const dailyCheckDiv = document.getElementById('userAgreementUploadDailyCheck'); + + if (contentDiv) { + // Render markdown if marked is available + if (typeof marked !== 'undefined') { + let html = marked.parse(agreementText); + // Sanitize if DOMPurify is available + if (typeof DOMPurify !== 'undefined') { + html = DOMPurify.sanitize(html); + } + contentDiv.innerHTML = html; + } else { + // Fallback: preserve line breaks + contentDiv.textContent = agreementText; + } + } + + // Show/hide daily acceptance info + if (dailyCheckDiv) { + dailyCheckDiv.style.display = enableDailyAcceptance ? 'block' : 'none'; + } + + // Show modal + if (modal) { + modal.show(); + } + } + + /** + * Handle accept button click + */ + async function onAccept() { + if (!pendingCallback || !pendingFiles) { + if (modal) modal.hide(); + return; + } + + // Get workspace info from pending state + const workspaceType = pendingFiles.workspaceType; + const workspaceId = pendingFiles.workspaceId; + + // Record acceptance + await recordAcceptance(workspaceType, workspaceId); + + // Hide modal + if (modal) modal.hide(); + + // Execute the pending callback with the files + const callback = pendingCallback; + const files = pendingFiles.files; + + // Clear pending state + pendingCallback = null; + pendingFiles = null; + + // Execute upload + callback(files); + } + + /** + * Handle cancel button click + */ + function onCancel() { + pendingCallback = null; + pendingFiles = null; + if (modal) modal.hide(); + } + + /** + * Check for user agreement and prompt if needed before file upload + * @param {FileList|File[]} files - The files to upload + * @param {string} workspaceType - 'personal', 'group', 'public', or 'chat' + * @param {string} workspaceId - The workspace ID + * @param {Function} uploadCallback - Function to call with files if agreement is accepted + * @returns {Promise} - True if upload should proceed immediately, false if modal is shown + */ + async function checkBeforeUpload(files, workspaceType, workspaceId, uploadCallback) { + if (!files || files.length === 0) { + return false; + } + + // Check if agreement is needed + const result = await checkAgreement(workspaceType, workspaceId); + + if (!result.needsAgreement) { + // No agreement needed, proceed with upload + uploadCallback(files); + return true; + } + + // Agreement is needed - show modal + pendingCallback = uploadCallback; + pendingFiles = { + files: files, + workspaceType: workspaceType, + workspaceId: workspaceId + }; + + showModal(result.agreementText, result.enableDailyAcceptance); + return false; + } + + // Public API + return { + init: init, + checkBeforeUpload: checkBeforeUpload, + checkAgreement: checkAgreement, + recordAcceptance: recordAcceptance + }; +})(); + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', function() { + window.UserAgreementManager.init(); +}); diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js index 27f11bd2..f6695fa3 100644 --- a/application/single_app/static/js/workspace/workspace-documents.js +++ b/application/single_app/static/js/workspace/workspace-documents.js @@ -354,10 +354,22 @@ async function uploadWorkspaceFiles(files) { // Upload Button Handler const uploadArea = document.getElementById("upload-area"); if (fileInput && uploadArea && uploadStatusSpan) { - // Auto-upload on file selection + // Auto-upload on file selection (with user agreement check) fileInput.addEventListener("change", () => { if (fileInput.files && fileInput.files.length > 0) { - uploadWorkspaceFiles(fileInput.files); + // Check for user agreement before uploading + if (window.UserAgreementManager) { + window.UserAgreementManager.checkBeforeUpload( + fileInput.files, + 'personal', + 'default', + function(files) { + uploadWorkspaceFiles(files); + } + ); + } else { + uploadWorkspaceFiles(fileInput.files); + } } }); @@ -385,7 +397,19 @@ if (fileInput && uploadArea && uploadStatusSpan) { uploadArea.classList.remove("dragover"); uploadArea.style.borderColor = ""; if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { - uploadWorkspaceFiles(e.dataTransfer.files); + // Check for user agreement before uploading (drag-and-drop) + if (window.UserAgreementManager) { + window.UserAgreementManager.checkBeforeUpload( + e.dataTransfer.files, + 'personal', + 'default', + function(files) { + uploadWorkspaceFiles(files); + } + ); + } else { + uploadWorkspaceFiles(e.dataTransfer.files); + } } }); } diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index 531f4074..b9cabf09 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -399,6 +399,11 @@ Retention Policy +