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 @@