diff --git a/api/routes/auth.py b/api/routes/auth.py index 0fc6d8cf..f48a87b7 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -150,7 +150,6 @@ async def google_authorized(request: Request) -> RedirectResponse: user_info = token.get("userinfo") if user_info: - user_data = { 'id': user_info.get('id') or user_info.get('sub'), 'email': user_info.get('email'), @@ -163,7 +162,7 @@ async def google_authorized(request: Request) -> RedirectResponse: if handler: api_token = secrets.token_urlsafe(32) # ~43 chars, hard to guess - # call the registered handler (await if async) + # Call the registered handler (await if async) await handler('google', user_data, api_token) redirect = RedirectResponse(url="/chat", status_code=302) @@ -176,9 +175,16 @@ async def google_authorized(request: Request) -> RedirectResponse: return redirect + # Handler not set - log and raise error to prevent silent failure + logging.error("Google OAuth callback handler not registered in app state") + raise HTTPException(status_code=500, detail="Authentication handler not configured") + + # If we reach here, user_info was falsy + logging.warning("No user info received from Google OAuth") raise HTTPException(status_code=400, detail="Failed to get user info from Google") except Exception as e: + logging.error(f"Google OAuth authentication failed: {str(e)}") raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}") @@ -232,10 +238,9 @@ async def github_authorized(request: Request) -> RedirectResponse: break if user_info: - user_data = { 'id': user_info.get('id'), - 'email': user_info.get('email'), + 'email': email, 'name': user_info.get('name'), 'picture': user_info.get('avatar_url'), } @@ -243,10 +248,9 @@ async def github_authorized(request: Request) -> RedirectResponse: # Call the registered GitHub callback handler if it exists to store user data. handler = getattr(request.app.state, "callback_handler", None) if handler: - api_token = secrets.token_urlsafe(32) # ~43 chars, hard to guess - # call the registered handler (await if async) + # Call the registered handler (await if async) await handler('github', user_data, api_token) redirect = RedirectResponse(url="/chat", status_code=302) @@ -259,9 +263,16 @@ async def github_authorized(request: Request) -> RedirectResponse: return redirect + # Handler not set - log and raise error to prevent silent failure + logging.error("GitHub OAuth callback handler not registered in app state") + raise HTTPException(status_code=500, detail="Authentication handler not configured") + + # If we reach here, user_info was falsy + logging.warning("No user info received from GitHub OAuth") raise HTTPException(status_code=400, detail="Failed to get user info from Github") except Exception as e: + logging.error(f"GitHub OAuth authentication failed: {str(e)}") raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}") diff --git a/api/routes/graphs.py b/api/routes/graphs.py index ad2eac71..ebb108a9 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Request, HTTPException, UploadFile, File from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel +from redis import ResponseError from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent from api.auth.user_management import token_required @@ -84,10 +85,25 @@ def sanitize_query(query: str) -> str: return query.replace('\n', ' ').replace('\r', ' ')[:500] def sanitize_log_input(value: str) -> str: - """Sanitize input for safe logging (remove newlines and carriage returns).""" + """ + Sanitize input for safe logging—remove newlines, + carriage returns, tabs, and wrap in repr(). + """ if not isinstance(value, str): - return str(value) - return value.replace('\n', ' ').replace('\r', ' ') + value = str(value) + + return value.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ') + +def _graph_name(request: Request, graph_id:str) -> str: + if not graph_id or not isinstance(graph_id, str): + raise HTTPException(status_code=400, detail="Invalid graph_id") + + graph_id = graph_id.strip()[:200] + if not graph_id: + raise HTTPException(status_code=400, + detail="Invalid graph_id, must be less than 200 characters.") + + return f"{request.state.user_id}_{graph_id}" @graphs_router.get("") @token_required @@ -112,12 +128,7 @@ async def get_graph_data(request: Request, graph_id: str): Nodes contain a minimal set of properties (id, name, labels, props). Edges contain source and target node names (or internal ids), type and props. """ - if not graph_id or not isinstance(graph_id, str): - return JSONResponse(content={"error": "Invalid graph_id"}, status_code=400) - - graph_id = graph_id.strip()[:200] - namespaced = f"{request.state.user_id}_{graph_id}" - + namespaced = _graph_name(request, graph_id) try: graph = db.select_graph(namespaced) except Exception as e: @@ -269,16 +280,7 @@ async def query_graph(request: Request, graph_id: str, chat_data: ChatRequest): """ text2sql """ - # Input validation - if not graph_id or not isinstance(graph_id, str): - raise HTTPException(status_code=400, detail="Invalid graph_id") - - # Sanitize graph_id to prevent injection - graph_id = graph_id.strip()[:100] # Limit length and strip whitespace - if not graph_id: - raise HTTPException(status_code=400, detail="Invalid graph_id") - - graph_id = f"{request.state.user_id}_{graph_id}" + graph_id = _graph_name(request, graph_id) queries_history = chat_data.chat if hasattr(chat_data, 'chat') else None result_history = chat_data.result if hasattr(chat_data, 'result') else None @@ -553,7 +555,8 @@ async def confirm_destructive_operation( """ Handle user confirmation for destructive SQL operations """ - graph_id = f"{request.state.user_id}_{graph_id.strip()}" + + graph_id = _graph_name(request, graph_id) if hasattr(confirm_data, 'confirmation'): confirmation = confirm_data.confirmation.strip().upper() @@ -674,7 +677,7 @@ async def refresh_graph_schema(request: Request, graph_id: str): This endpoint allows users to manually trigger a schema refresh if they suspect the graph is out of sync with the database. """ - graph_id = f"{request.state.user_id}_{graph_id.strip()}" + graph_id = _graph_name(request, graph_id) try: # Get database connection details @@ -716,3 +719,27 @@ async def refresh_graph_schema(request: Request, graph_id: str): "success": False, "error": "Error refreshing schema" }, status_code=500) + +@graphs_router.delete("/{graph_id}") +@token_required +async def delete_graph(request: Request, graph_id: str): + """Delete the specified graph (namespaced to the user). + + This will attempt to delete the FalkorDB graph belonging to the + authenticated user. The graph id used by the client is stripped of + namespace and will be namespaced using the user's id from the request + state. + """ + namespaced = _graph_name(request, graph_id) + + try: + # Select and delete the graph using the FalkorDB client API + graph = db.select_graph(namespaced) + await graph.delete() + return JSONResponse(content={"success": True, "graph": graph_id}) + except ResponseError: + return JSONResponse(content={"error": "Failed to delete graph, Graph not found"}, + status_code=404) + except Exception as e: + logging.exception("Failed to delete graph %s: %s", sanitize_log_input(namespaced), e) + return JSONResponse(content={"error": "Failed to delete graph"}, status_code=500) diff --git a/app/public/css/menu.css b/app/public/css/menu.css index b09bdea8..91d6a85c 100644 --- a/app/public/css/menu.css +++ b/app/public/css/menu.css @@ -331,3 +331,91 @@ height: 16px; flex-shrink: 0; } + +/* Graph custom dropdown (moved from chat_header.j2 inline styles) */ +.graph-custom-dropdown { + position: relative; + display: inline-block; + width: 180px; + margin-left: 8px; +} + +.graph-selected { + padding: 8px 14px; + border-radius: 6px; + background: var(--falkor-quaternary); + color: var(--text-primary); + cursor: pointer; + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-width: 160px; + box-sizing: border-box; + font-size: 14px; +} + +.graph-options { + position: absolute; + top: calc(100%); + left: 0; + right: 0; + background: var(--falkor-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + max-height: 260px; + overflow: auto; + display: none; + z-index: 50; +} + +.dropdown-option { + display: flex; + align-items: center; + justify-content: flex-start; + padding: 8px 12px; + gap: 8px; + color: var(--text-primary); + cursor: pointer; +} + +.dropdown-option:hover { + background: var(--bg-tertiary); +} + +.dropdown-option span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-option .delete-btn { + background: transparent; + border: none; + color: #ff6b6b; + opacity: 0; + cursor: pointer; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; +} + +.dropdown-option:hover .delete-btn { + opacity: 1; +} + +.dropdown-option .delete-btn svg { + width: 16px; + height: 16px; +} + +.graph-options.open { + display: block; +} \ No newline at end of file diff --git a/app/templates/components/chat_header.j2 b/app/templates/components/chat_header.j2 index f03a037c..6bad7b49 100644 --- a/app/templates/components/chat_header.j2 +++ b/app/templates/components/chat_header.j2 @@ -3,9 +3,16 @@

Natural Language to SQL Generator

- + +
+ + +
+ +