diff --git a/api/app_factory.py b/api/app_factory.py index 17bb7a3e..bd934d6e 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -2,7 +2,6 @@ import logging import os -import secrets from dotenv import load_dotenv from fastapi import FastAPI, Request, HTTPException @@ -12,6 +11,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from api.auth.oauth_handlers import setup_oauth_handlers +from api.auth.user_management import SECRET_KEY from api.routes.auth import auth_router, init_auth from api.routes.graphs import graphs_router from api.routes.database import database_router @@ -20,7 +20,6 @@ load_dotenv() logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") - class SecurityMiddleware(BaseHTTPMiddleware): """Middleware for security checks including static file access""" @@ -52,16 +51,10 @@ def create_app(): ), ) - # Get secret key for sessions - secret_key = os.getenv("FASTAPI_SECRET_KEY") - if not secret_key: - secret_key = secrets.token_hex(32) - logging.warning("FASTAPI_SECRET_KEY not set, using generated key. Set this in production!") - # Add session middleware with explicit settings to ensure OAuth state persists app.add_middleware( SessionMiddleware, - secret_key=secret_key, + secret_key=SECRET_KEY, session_cookie="qw_session", same_site="lax", # allow top-level OAuth GET redirects to send cookies https_only=False, # allow http on localhost in development @@ -90,9 +83,9 @@ def create_app(): async def handle_oauth_error(request: Request, exc: Exception): """Handle OAuth-related errors gracefully""" # Check if it's an OAuth-related error + # TODO check this scenario if "token" in str(exc).lower() or "oauth" in str(exc).lower(): logging.warning("OAuth error occurred: %s", exc) - request.session.clear() return RedirectResponse(url="/", status_code=302) # If it's an HTTPException, re-raise so FastAPI handles it properly diff --git a/api/auth/__init__.py b/api/auth/__init__.py index 853c3c6b..793b1f17 100644 --- a/api/auth/__init__.py +++ b/api/auth/__init__.py @@ -7,7 +7,7 @@ from .user_management import ( ensure_user_in_organizations, update_identity_last_login, - validate_and_cache_user, + validate_user, token_required, ) from .oauth_handlers import setup_oauth_handlers @@ -15,7 +15,7 @@ __all__ = [ "ensure_user_in_organizations", "update_identity_last_login", - "validate_and_cache_user", + "validate_user", "token_required", "setup_oauth_handlers", ] diff --git a/api/auth/oauth_handlers.py b/api/auth/oauth_handlers.py index b9c216cb..8e25198a 100644 --- a/api/auth/oauth_handlers.py +++ b/api/auth/oauth_handlers.py @@ -7,7 +7,7 @@ import logging from typing import Dict, Any -from fastapi import FastAPI, Request +from fastapi import FastAPI from authlib.integrations.starlette_client import OAuth from .user_management import ensure_user_in_organizations @@ -19,10 +19,8 @@ def setup_oauth_handlers(app: FastAPI, oauth: OAuth): # Store oauth in app state for access in routes app.state.oauth = oauth - async def handle_google_callback(_request: Request, - _token: Dict[str, Any], - user_info: Dict[str, Any]): - """Handle Google OAuth callback processing""" + async def handle_callback(provider: str, user_info: Dict[str, Any], api_token: str): + """Handle Provider OAuth callback processing""" try: user_id = user_info.get("id") email = user_info.get("email") @@ -30,7 +28,7 @@ async def handle_google_callback(_request: Request, # Validate required fields if not user_id or not email: - logging.error("Missing required fields from Google OAuth response") + logging.error("Missing required fields from %s OAuth response", provider) return False # Check if identity exists in Organizations graph, create if new @@ -38,43 +36,15 @@ async def handle_google_callback(_request: Request, user_id, email, name, - "google", + provider, + api_token, user_info.get("picture"), ) return True except Exception as exc: # capture exception for logging - logging.error("Error handling Google OAuth callback: %s", exc) - return False - - async def handle_github_callback(_request: Request, - _token: Dict[str, Any], - user_info: Dict[str, Any]): - """Handle GitHub OAuth callback processing""" - try: - user_id = user_info.get("id") - email = user_info.get("email") - name = user_info.get("name") or user_info.get("login") - - # Validate required fields - if not user_id or not email: - logging.error("Missing required fields from GitHub OAuth response") - return False - - # Check if identity exists in Organizations graph, create if new - _, _ = await ensure_user_in_organizations( - user_id, - email, - name, - "github", - user_info.get("picture"), - ) - - return True - except Exception as exc: # capture exception for logging - logging.error("Error handling GitHub OAuth callback: %s", exc) + logging.error("Error handling %s OAuth callback: %s", provider, exc) return False # Store handlers in app state for use in route callbacks - app.state.google_callback_handler = handle_google_callback - app.state.github_callback_handler = handle_github_callback + app.state.callback_handler = handle_callback diff --git a/api/auth/user_management.py b/api/auth/user_management.py index ea5c8baa..a33d9fdb 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -1,19 +1,77 @@ """User management and authentication functions for text2sql API.""" +import base64 import logging -import time +import os +import secrets from functools import wraps from typing import Tuple, Optional, Dict, Any -import requests from fastapi import Request, HTTPException, status -from fastapi.responses import JSONResponse -from authlib.integrations.starlette_client import OAuth - from api.extensions import db +# Get secret key for sessions +SECRET_KEY = os.getenv("FASTAPI_SECRET_KEY") +if not SECRET_KEY: + SECRET_KEY = secrets.token_hex(32) + logging.warning("FASTAPI_SECRET_KEY not set, using generated key. Set this in production!") + + +async def _get_user_info(api_token: str) -> Optional[Dict[str, Any]]: + """ + Get user information from the database by email. + """ + query = """ + MATCH (i:Identity)-[:HAS_TOKEN]->(t:Token {id: $api_token}) + RETURN i.email, i.name, i.picture, (t IS NOT NULL AND timestamp() <= t.expires_at) AS token_valid + """ + + try: + # Select the Organizations graph + organizations_graph = db.select_graph("Organizations") + + result = await organizations_graph.query(query, { + "api_token": api_token, + }) + + if result.result_set: + token_valid = result.result_set[0][3] + + # TODO delete invalid token from DB + if token_valid: + return { + "email": result.result_set[0][0], + "name": result.result_set[0][1], + "picture": result.result_set[0][2] + } + + return None + except Exception as e: + logging.error("Error fetching user info: %s", e) + return None + -async def ensure_user_in_organizations(provider_user_id, email, name, provider, picture=None): +async def delete_user_token(api_token: str): + """ + Delete user token from the database. + """ + query = """ + MATCH (t:Token {id:$api_token}) + DELETE t + """ + try: + # Select the Organizations graph + organizations_graph = db.select_graph("Organizations") + + await organizations_graph.query(query, { + "api_token": api_token, + }) + + except Exception as e: + logging.error("Error deleting user token: %s", e) + + +async def ensure_user_in_organizations(provider_user_id: str, email: str, name: str, provider: str, api_token: str, picture: str = None): """ Check if identity exists in Organizations graph, create if not. Creates separate Identity and User nodes with proper relationships. @@ -71,6 +129,13 @@ async def ensure_user_in_organizations(provider_user_id, email, name, provider, // Ensure relationship exists MERGE (identity)-[:AUTHENTICATES]->(user) + + // Then, create a session linked to the Identity and store the API_Token + MERGE (token:Token {id: $api_token}) + ON CREATE SET + token.created_at = timestamp(), + token.expires_at = timestamp() + 86400000 // 24h expiry + MERGE (identity)-[:HAS_TOKEN]->(token) // Return results with flags to determine if this was a new user/identity RETURN @@ -87,7 +152,8 @@ async def ensure_user_in_organizations(provider_user_id, email, name, provider, "name": name, "picture": picture, "first_name": first_name, - "last_name": last_name + "last_name": last_name, + "api_token": api_token }) if result.result_set: @@ -159,122 +225,28 @@ async def update_identity_last_login(provider, provider_user_id): provider, provider_user_id, e) -async def validate_and_cache_user(request: Request) -> Tuple[Optional[Dict[str, Any]], bool]: +async def validate_user(request: Request) -> Tuple[Optional[Dict[str, Any]], bool]: """ - Helper function to validate OAuth token and cache user info. + Helper function to validate token. Returns (user_info, is_authenticated). - Supports both Google and GitHub OAuth. Includes refresh handling for Google. """ try: - user_info = request.session.get("user_info") - token_validated_at = request.session.get("token_validated_at", 0) - current_time = time.time() - - # Use cached user info if it's less than 15 minutes old - if user_info and (current_time - token_validated_at) < 900: - return user_info, True - - oauth: OAuth = request.app.state.oauth - - # ---- Google OAuth ---- - google_token = request.session.get("google_token") - if google_token and hasattr(oauth, "google"): - try: - resp = await oauth.google.get("/oauth2/v2/userinfo", token=google_token) - - if resp.status_code == 401 and "refresh_token" in google_token: - # Token expired, try refreshing - try: - new_token = await oauth.google.refresh_token( - "https://oauth2.googleapis.com/token", - refresh_token=google_token["refresh_token"], - ) - request.session["google_token"] = new_token - resp = await oauth.google.get("/oauth2/v2/userinfo", token=new_token) - logging.info("Google access token refreshed successfully") - except Exception as e: - logging.error("Google token refresh failed: %s", e) - request.session.pop("google_token", None) - request.session.pop("user_info", None) - return None, False - - if resp.status_code == 200: - google_user = resp.json() - if not google_user.get("id") or not google_user.get("email"): - logging.warning("Invalid Google user data received") - request.session.pop("google_token", None) - request.session.pop("user_info", None) - return None, False - - # Normalize - user_info = { - "id": str(google_user.get("id")), - "name": google_user.get("name", ""), - "email": google_user.get("email"), - "picture": google_user.get("picture", ""), - "provider": "google", - } - request.session["user_info"] = user_info - request.session["token_validated_at"] = current_time - return user_info, True - except Exception as e: - logging.warning("Google OAuth validation error: %s", e) - request.session.pop("google_token", None) - request.session.pop("user_info", None) - - # ---- GitHub OAuth ---- - github_token = request.session.get("github_token") - if github_token and hasattr(oauth, "github"): - try: - resp = await oauth.github.get("/user", token=github_token) - if resp.status_code == 200: - github_user = resp.json() - if not github_user.get("id"): - logging.warning("Invalid GitHub user data received") - request.session.pop("github_token", None) - request.session.pop("user_info", None) - return None, False - - # Get primary email - email_resp = await oauth.github.get("/user/emails", token=github_token) - email = None - if email_resp.status_code == 200: - for email_obj in email_resp.json(): - if email_obj.get("primary", False): - email = email_obj.get("email") - break - if not email and email_resp.json(): - email = email_resp.json()[0].get("email") - - if not email: - logging.warning("No email found for GitHub user") - request.session.pop("github_token", None) - request.session.pop("user_info", None) - return None, False - - user_info = { - "id": str(github_user.get("id")), - "name": github_user.get("name") or github_user.get("login", ""), - "email": email, - "picture": github_user.get("avatar_url", ""), - "provider": "github", - } - request.session["user_info"] = user_info - request.session["token_validated_at"] = current_time - return user_info, True - except Exception as e: - logging.warning("GitHub OAuth validation error: %s", e) - request.session.pop("github_token", None) - request.session.pop("user_info", None) - - # No valid auth - request.session.pop("user_info", None) + # token might be in the URL if not in the cookie for API access + api_token = request.cookies.get("api_token") + if not api_token: + api_token = request.query_params.get("api_token") + + if api_token: + db_info = await _get_user_info(api_token) + + if db_info: + return db_info, True + return None, False except Exception as e: - logging.error("Unexpected error in validate_and_cache_user: %s", e) - request.session.pop("user_info", None) + logging.error("Unexpected error in validate_user: %s", e) return None, False def token_required(func): @@ -285,12 +257,11 @@ def token_required(func): @wraps(func) async def wrapper(request: Request, *args, **kwargs): try: - user_info, is_authenticated = await validate_and_cache_user(request) + user_info, is_authenticated = await validate_user(request) if not is_authenticated: # Second attempt after clearing session to force re-validation - request.session.pop("user_info", None) - user_info, is_authenticated = await validate_and_cache_user(request) + user_info, is_authenticated = await validate_user(request) if not is_authenticated: raise HTTPException( @@ -299,9 +270,9 @@ async def wrapper(request: Request, *args, **kwargs): ) # Attach user_id to request.state (like FASTAPI's g.user_id) - request.state.user_id = user_info.get("id") + # we're using the email as BASE64 encoded + request.state.user_id = base64.b64encode(user_info.get("email").encode()).decode() if not request.state.user_id: - request.session.pop("user_info", None) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Invalid user" @@ -313,7 +284,6 @@ async def wrapper(request: Request, *args, **kwargs): raise except Exception as e: logging.error("Unexpected error in token_required: %s", e) - request.session.pop("user_info", None) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Authentication error" diff --git a/api/loaders/base_loader.py b/api/loaders/base_loader.py index d6418382..735a9a57 100644 --- a/api/loaders/base_loader.py +++ b/api/loaders/base_loader.py @@ -8,7 +8,7 @@ class BaseLoader(ABC): """Abstract base class for data loaders.""" @staticmethod - def load(_graph_id: str, _data) -> Tuple[bool, str]: + async def load(_graph_id: str, _data) -> Tuple[bool, str]: """ Load the graph data into the database. This method must be implemented by any subclass. diff --git a/api/loaders/mysql_loader.py b/api/loaders/mysql_loader.py index a174577c..5c22c03a 100644 --- a/api/loaders/mysql_loader.py +++ b/api/loaders/mysql_loader.py @@ -158,7 +158,7 @@ async def load(prefix: str, connection_url: str) -> Tuple[bool, str]: conn.close() # Load data into graph - await load_to_graph(prefix + "_" + db_name, entities, relationships, + await load_to_graph(f"{prefix}_{db_name}", entities, relationships, db_name=db_name, db_url=connection_url) return True, (f"MySQL schema loaded successfully. " diff --git a/api/loaders/postgres_loader.py b/api/loaders/postgres_loader.py index d3e60989..0ac50e49 100644 --- a/api/loaders/postgres_loader.py +++ b/api/loaders/postgres_loader.py @@ -96,7 +96,7 @@ async def load(prefix: str, connection_url: str) -> Tuple[bool, str]: conn.close() # Load data into graph - await load_to_graph(prefix + "_" + db_name, entities, relationships, + await load_to_graph(f"{prefix}_{db_name}", entities, relationships, db_name=db_name, db_url=connection_url) return True, (f"PostgreSQL schema loaded successfully. " diff --git a/api/routes/auth.py b/api/routes/auth.py index 2bb79d2b..1843b750 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -2,20 +2,18 @@ import logging import os -import time +import secrets from pathlib import Path from urllib.parse import urljoin -import httpx from fastapi import APIRouter, Request, HTTPException, status from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.templating import Jinja2Templates -from authlib.common.errors import AuthlibBaseError from authlib.integrations.starlette_client import OAuth from jinja2 import Environment, FileSystemLoader, FileSystemBytecodeCache, select_autoescape from starlette.config import Config -from api.auth.user_management import validate_and_cache_user +from api.auth.user_management import delete_user_token, validate_user # Router auth_router = APIRouter() @@ -50,21 +48,15 @@ def _get_provider_client(request: Request, provider: str): raise HTTPException(status_code=500, detail=f"OAuth provider {provider} not configured") return client -def _clear_auth_session(session: dict): - """Remove only auth-related keys from session instead of clearing everything.""" - for key in [ - "user_info", - "google_token", - "github_token", - "token_validated_at", - "oauth_google_auth", - ]: - session.pop(key, None) - @auth_router.get("/chat", name="auth.chat", response_class=HTMLResponse) async def chat(request: Request) -> HTMLResponse: """Explicit chat route (renders main chat UI).""" - user_info, is_authenticated = await validate_and_cache_user(request) + user_info, is_authenticated = await validate_user(request) + + if not is_authenticated or not user_info: + is_authenticated = False + user_info = None + return templates.TemplateResponse( "chat.j2", { @@ -85,31 +77,32 @@ def _build_callback_url(request: Request, path: str) -> str: # ---- Routes ---- @auth_router.get("/", response_class=HTMLResponse) async def home(request: Request) -> HTMLResponse: - user_info, is_authenticated_flag = await validate_and_cache_user(request) + """Handle the home page, rendering the landing page for unauthenticated users and the chat page for authenticated users.""" + user_info, is_authenticated_flag = await validate_user(request) - if not is_authenticated_flag: - _clear_auth_session(request.session) - - if not is_authenticated_flag: + if is_authenticated_flag or user_info: return templates.TemplateResponse( - "landing.j2", + "chat.j2", { "request": request, - "is_authenticated": False, - "user_info": None + "is_authenticated": True, + "user_info": user_info } ) return templates.TemplateResponse( - "chat.j2", + "landing.j2", { - "request": request, - "is_authenticated": is_authenticated_flag, - "user_info": user_info, - }, + "request": request, + "is_authenticated": False, + "user_info": None + } ) + + + @auth_router.get("/login", response_class=RedirectResponse) async def login_page(_: Request) -> RedirectResponse: return RedirectResponse(url="/login/google", status_code=status.HTTP_302_FOUND) @@ -117,6 +110,15 @@ async def login_page(_: Request) -> RedirectResponse: @auth_router.get("/login/google", name="google.login", response_class=RedirectResponse) async def login_google(request: Request) -> RedirectResponse: + """Initiate Google OAuth login flow. + + Args: + request (Request): The incoming request. + + Returns: + RedirectResponse: The redirect response to the Google OAuth endpoint. + """ + google = _get_provider_client(request, "google") redirect_uri = _build_callback_url(request, "login/google/authorized") @@ -133,46 +135,51 @@ async def login_google(request: Request) -> RedirectResponse: @auth_router.get("/login/google/authorized", response_class=RedirectResponse) async def google_authorized(request: Request) -> RedirectResponse: + """Handle Google OAuth callback and user authorization. + + Args: + request (Request): The incoming request. + + Returns: + RedirectResponse: The redirect response after handling the callback. + """ + try: google = _get_provider_client(request, "google") token = await google.authorize_access_token(request) + user_info = token.get("userinfo") - # Always fetch userinfo explicitly - resp = await google.get("https://www.googleapis.com/oauth2/v2/userinfo", token=token) - if resp.status_code != 200: - logging.error("Failed to fetch Google user info: %s", resp.text) - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + if user_info: - user_info = resp.json() - if not user_info.get("email"): - logging.error("Invalid Google user data received") - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + user_data = { + 'id': user_info.get('id') or user_info.get('sub'), + 'email': user_info.get('email'), + 'name': user_info.get('name'), + 'picture': user_info.get('picture'), + } - # Normalize - request.session["user_info"] = { - "id": str(user_info.get("id") or user_info.get("sub")), - "name": user_info.get("name", ""), - "email": user_info.get("email"), - "picture": user_info.get("picture", ""), - "provider": "google", - } - request.session["google_token"] = token - request.session["token_validated_at"] = time.time() + # Call the registered Google 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) + await handler('google', user_data, api_token) - # Call the registered Google callback handler if it exists to store user data. - handler = getattr(request.app.state, "google_callback_handler", None) - if handler: - # call the registered handler (await if async) - await handler(request, token, user_info) + redirect = RedirectResponse(url="/", status_code=302) + redirect.set_cookie( + key="api_token", + value=api_token, + httponly=True, + secure=True + ) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + return redirect - except AuthlibBaseError as e: - logging.error("Google OAuth error: %s", e) - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + raise HTTPException(status_code=400, detail="Failed to get user info from Google") + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}") @auth_router.get("/login/google/callback", response_class=RedirectResponse) @@ -205,10 +212,9 @@ async def github_authorized(request: Request) -> RedirectResponse: token = await github.authorize_access_token(request) # Fetch GitHub user info - resp = await github.get("https://api.github.com/user", token=token) + resp = await github.get("user", token=token) if resp.status_code != 200: logging.error("Failed to fetch GitHub user info: %s", resp.text) - _clear_auth_session(request.session) return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) user_info = resp.json() @@ -217,7 +223,7 @@ async def github_authorized(request: Request) -> RedirectResponse: email = user_info.get("email") if not email: # Try to get primary email from emails endpoint - email_resp = await github.get("https://api.github.com/user/emails", token=token) + email_resp = await github.get("user/emails", token=token) if email_resp.status_code == 200: emails = email_resp.json() for email_obj in emails: @@ -225,34 +231,38 @@ async def github_authorized(request: Request) -> RedirectResponse: email = email_obj.get("email") break - if not user_info.get("id") or not email: - logging.error("Invalid GitHub user data received") - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + if user_info: - # Normalize user info structure - request.session["user_info"] = { - "id": str(user_info.get("id")), - "name": user_info.get("name") or user_info.get("login", ""), - "email": email, - "picture": user_info.get("avatar_url", ""), - "provider": "github", - } - request.session["github_token"] = token - request.session["token_validated_at"] = time.time() + user_data = { + 'id': user_info.get('id'), + 'email': user_info.get('email'), + 'name': user_info.get('name'), + 'picture': user_info.get('avatar_url'), + } + + # 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 GitHub callback handler if it exists to store user data. - handler = getattr(request.app.state, "github_callback_handler", None) - if handler: - # call the registered handler (await if async) - await handler(request, token, user_info) + # call the registered handler (await if async) + await handler('github', user_data, api_token) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + redirect = RedirectResponse(url="/", status_code=302) + redirect.set_cookie( + key="api_token", + value=api_token, + httponly=True, + secure=True + ) - except AuthlibBaseError as e: - logging.error("GitHub OAuth error: %s", e) - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + return redirect + + raise HTTPException(status_code=400, detail="Failed to get user info from Github") + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}") @auth_router.get("/login/github/callback", response_class=RedirectResponse) @@ -264,48 +274,15 @@ async def github_callback_compat(request: Request) -> RedirectResponse: @auth_router.get("/logout", response_class=RedirectResponse) async def logout(request: Request) -> RedirectResponse: - """Handle user logout and revoke tokens for Google (actively) and GitHub (locally).""" - google_token = request.session.get("google_token") - github_token = request.session.get("github_token") - - # ---- Revoke Google tokens ---- - if google_token: - tokens_to_revoke = [] - if access_token := google_token.get("access_token"): - tokens_to_revoke.append(access_token) - if refresh_token := google_token.get("refresh_token"): - tokens_to_revoke.append(refresh_token) - - if tokens_to_revoke: - try: - async with httpx.AsyncClient() as client: - for token in tokens_to_revoke: - resp = await client.post( - "https://oauth2.googleapis.com/revoke", - params={"token": token}, - headers={"content-type": "application/x-www-form-urlencoded"}, - ) - if resp.status_code != 200: - logging.warning( - "Google token revoke failed (%s): %s", - resp.status_code, - resp.text, - ) - else: - logging.info("Successfully revoked Google token") - except Exception as e: - logging.error("Error revoking Google tokens: %s", e) - - # ---- Handle GitHub tokens ---- - if github_token: - logging.info("GitHub token found, clearing from session (no remote revoke available).") - # GitHub logout is local only unless we call the App management API - - # ---- Clear session auth keys ---- - for key in ["user_info", "google_token", "github_token", "token_validated_at"]: - request.session.pop(key, None) - - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + """Handle user logout and delete session cookies.""" + resp = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + + api_token = request.cookies.get("api_token") + if api_token: + resp.delete_cookie("api_token") + await delete_user_token(api_token) + + return resp # ---- Hook for app factory ---- def init_auth(app): diff --git a/api/routes/graphs.py b/api/routes/graphs.py index 275810d6..ad2eac71 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -4,8 +4,6 @@ import json import logging import time -from concurrent.futures import ThreadPoolExecutor -from concurrent.futures import TimeoutError as FuturesTimeoutError from fastapi import APIRouter, Request, HTTPException, UploadFile, File from fastapi.responses import JSONResponse, StreamingResponse @@ -118,7 +116,7 @@ async def get_graph_data(request: Request, graph_id: str): return JSONResponse(content={"error": "Invalid graph_id"}, status_code=400) graph_id = graph_id.strip()[:200] - namespaced = request.state.user_id + "_" + graph_id + namespaced = f"{request.state.user_id}_{graph_id}" try: graph = db.select_graph(namespaced) @@ -222,7 +220,7 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile if not hasattr(data, 'database') or not data.database: raise HTTPException(status_code=400, detail="Invalid JSON data") - graph_id = request.state.user_id + "_" + data.database + graph_id = f"{request.state.user_id}_{data.database}" success, result = await JSONLoader.load(graph_id, data.dict()) # ✅ Handle File Upload @@ -234,7 +232,7 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile if filename.endswith(".json"): try: data = json.loads(content.decode("utf-8")) - graph_id = request.state.user_id + "_" + data.get("database", "") + graph_id = f"{request.state.user_id}_{data.get('database', '')}" success, result = await JSONLoader.load(graph_id, data) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON file") @@ -242,13 +240,13 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile # ✅ Check if file is XML elif filename.endswith(".xml"): xml_data = content.decode("utf-8") - graph_id = request.state.user_id + "_" + filename.replace(".xml", "") + graph_id = f"{request.state.user_id}_{filename.replace('.xml', '')}" success, result = await ODataLoader.load(graph_id, xml_data) # ✅ Check if file is csv elif filename.endswith(".csv"): csv_data = content.decode("utf-8") - graph_id = request.state.user_id + "_" + filename.replace(".csv", "") + graph_id = f"{request.state.user_id}_{filename.replace('.csv', '')}" success, result = await CSVLoader.load(graph_id, csv_data) else: @@ -280,7 +278,7 @@ async def query_graph(request: Request, graph_id: str, chat_data: ChatRequest): if not graph_id: raise HTTPException(status_code=400, detail="Invalid graph_id") - graph_id = request.state.user_id + "_" + graph_id + graph_id = f"{request.state.user_id}_{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 @@ -555,7 +553,7 @@ async def confirm_destructive_operation( """ Handle user confirmation for destructive SQL operations """ - graph_id = request.state.user_id + "_" + graph_id.strip() + graph_id = f"{request.state.user_id}_{graph_id.strip()}" if hasattr(confirm_data, 'confirmation'): confirmation = confirm_data.confirmation.strip().upper() @@ -676,7 +674,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 = request.state.user_id + "_" + graph_id.strip() + graph_id = f"{request.state.user_id}_{graph_id.strip()}" try: # Get database connection details diff --git a/app/package-lock.json b/app/package-lock.json index c24dedb7..04715822 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -11,7 +11,7 @@ "@typescript-eslint/eslint-plugin": "^8.40.0", "@typescript-eslint/parser": "^8.40.0", "esbuild": "^0.25.9", - "eslint": "^9.33.0", + "eslint": "^9.34.0", "typescript": "^5.9.2" } }, @@ -607,9 +607,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", - "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", "dev": true, "license": "MIT", "engines": { @@ -1239,9 +1239,9 @@ } }, "node_modules/eslint": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", - "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", "dependencies": { @@ -1251,7 +1251,7 @@ "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.33.0", + "@eslint/js": "9.34.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", diff --git a/app/package.json b/app/package.json index 0c39ce12..33f9270c 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "devDependencies": { "esbuild": "^0.25.9", "typescript": "^5.9.2", - "eslint": "^9.33.0", + "eslint": "^9.34.0", "@typescript-eslint/parser": "^8.40.0", "@typescript-eslint/eslint-plugin": "^8.40.0" } diff --git a/app/public/css/base.css b/app/public/css/base.css index 8174cafe..3fe58204 100644 --- a/app/public/css/base.css +++ b/app/public/css/base.css @@ -63,6 +63,11 @@ button, input, select, textarea { text-decoration: underline; } +#powered-by-falkordb img { + height:1.5em; + vertical-align:middle; +} + @media (max-width: 600px) { #powered-by-falkordb { font-size: 0.95em; diff --git a/app/public/css/buttons.css b/app/public/css/buttons.css index 500ea70c..28602d48 100644 --- a/app/public/css/buttons.css +++ b/app/public/css/buttons.css @@ -220,6 +220,7 @@ text-decoration: none; font-weight: 500; transition: background 0.2s; + margin-top: 0.5em; } .github-login-btn:hover { diff --git a/app/templates/base.j2 b/app/templates/base.j2 index ba07d051..6b08eca6 100644 --- a/app/templates/base.j2 +++ b/app/templates/base.j2 @@ -34,7 +34,7 @@ {% block scripts %} diff --git a/app/templates/components/left_toolbar.j2 b/app/templates/components/left_toolbar.j2 index 80fc624f..86386caf 100644 --- a/app/templates/components/left_toolbar.j2 +++ b/app/templates/components/left_toolbar.j2 @@ -72,80 +72,3 @@ - - \ No newline at end of file diff --git a/app/templates/components/login_modal.j2 b/app/templates/components/login_modal.j2 index 5489548c..af26b058 100644 --- a/app/templates/components/login_modal.j2 +++ b/app/templates/components/login_modal.j2 @@ -6,7 +6,7 @@ Sign in with Google - + Sign in with GitHub diff --git a/app/templates/components/toolbar.j2 b/app/templates/components/toolbar.j2 index c1831a6e..331a3752 100644 --- a/app/templates/components/toolbar.j2 +++ b/app/templates/components/toolbar.j2 @@ -5,10 +5,10 @@ - + - - + - {% if is_authenticated and user_info %} diff --git a/app/ts/app.ts b/app/ts/app.ts index bb1c1a8a..cde7a6a6 100644 --- a/app/ts/app.ts +++ b/app/ts/app.ts @@ -19,6 +19,7 @@ import { } from './modules/ui'; import { setupAuthenticationModal, setupDatabaseModal } from './modules/modals'; import { showGraph } from './modules/schema'; +import { initLeftToolbar } from './modules/left_toolbar'; async function loadAndShowGraph(selected: string | undefined) { if (!selected) return; @@ -102,6 +103,8 @@ function setupUIComponents() { setupAuthenticationModal(); setupDatabaseModal(); setupToolbar(); + // initialize left toolbar behavior (burger, responsive default) + initLeftToolbar(); setupCustomDropdown(); } diff --git a/app/ts/modules/left_toolbar.ts b/app/ts/modules/left_toolbar.ts new file mode 100644 index 00000000..94d2162a --- /dev/null +++ b/app/ts/modules/left_toolbar.ts @@ -0,0 +1,74 @@ +/** + * Left toolbar behavior extracted from template script. + * This module exports initLeftToolbar and can be imported so bundlers include it in the main bundle. + */ +export function initLeftToolbar(): void { + const nav = document.getElementById('left-toolbar') as HTMLElement | null; + const btn = document.getElementById('burger-toggle-btn') as HTMLElement | null; + if (!nav || !btn) return; + + function setOpen(open: boolean) { + // `nav` and `btn` are checked above; use non-null assertions to satisfy TypeScript + if (open) { + nav!.classList.remove('collapsed'); + document.body.classList.add('left-toolbar-open'); + btn!.setAttribute('title', 'Close menu'); + btn!.setAttribute('aria-label', 'Close menu'); + btn!.setAttribute('aria-pressed', 'true'); + btn!.setAttribute('aria-expanded', 'true'); + } else { + nav!.classList.add('collapsed'); + document.body.classList.remove('left-toolbar-open'); + btn!.setAttribute('title', 'Open menu'); + btn!.setAttribute('aria-label', 'Open menu'); + btn!.setAttribute('aria-pressed', 'false'); + btn!.setAttribute('aria-expanded', 'false'); + } + } + + const mq = window.matchMedia('(min-width: 768px)'); + + try { + setOpen(mq.matches); + } catch (e) { + setOpen(true); + } + + // Support both modern and legacy addListener APIs + if (typeof (mq as MediaQueryList).addEventListener === 'function') { + (mq as MediaQueryList).addEventListener('change', (ev: MediaQueryListEvent) => setOpen(ev.matches)); + } else if (typeof (mq as any).addListener === 'function') { + (mq as any).addListener((ev: MediaQueryListEvent) => setOpen(ev.matches)); + } + + let ignoreNextClick = false; + + function handleToggleEvent() { + const isCollapsed = nav!.classList.contains('collapsed'); + setOpen(isCollapsed); + } + + btn.addEventListener('pointerdown', function (e: PointerEvent) { + e.preventDefault(); + handleToggleEvent(); + ignoreNextClick = true; + }); + + btn.addEventListener('click', function (_e: MouseEvent) { + if (ignoreNextClick) { + ignoreNextClick = false; + return; + } + handleToggleEvent(); + }); + + // Expose a minimal API for other scripts + (window as any).__leftToolbar = { + open: () => setOpen(true), + close: () => setOpen(false), + toggle: () => setOpen(!nav.classList.contains('collapsed')), + }; +} + +// Note: We don't auto-init here. Importing module and calling initLeftToolbar() from the app entry +// ensures the bundler includes this file and initialization timing stays explicit.