Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .planning/debug/knowledge-base.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# GSD Debug Knowledge Base

Resolved debug sessions. Used by `gsd-debugger` to surface known-pattern hypotheses at the start of new investigations.

---

## signout-loading-forever — Sign out shows infinite loading instead of redirecting
- **Date:** 2026-04-01T00:05:00Z
- **Error patterns:** Loading user information, sign out, redirect, authentication
- **Root cause:** Settings page called signOut() without redirecting, keeping user on protected route with null user state, causing infinite "Loading user information" display
- **Fix:** Added router.push("/") after signOut call in Settings page, matching the Navigation component pattern
- **Files changed:** frontend/src/app/dashboard/settings/page.tsx
---
58 changes: 58 additions & 0 deletions .planning/debug/resolved/signout-loading-forever.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
status: resolved
trigger: "Investigate issue: signout-loading-forever - Sign out doesn't work - after clicking the sign out button, it shows \"Loading user information\" indefinitely instead of redirecting to login/landing page."
created: 2026-04-01T00:00:00Z
updated: 2026-04-01T00:05:00Z
---

## Current Focus
hypothesis: CONFIRMED: Sign out clears auth but doesn't redirect. User stays on /dashboard/settings page, which shows "Loading user information..." when user is null. The root cause is missing redirect logic in handleSignOut().
test: Add redirect to landing page after signOut clears auth
expecting: After sign out, user redirects to "/" instead of staying on protected route
next_action: Implement fix in auth-provider.tsx

## Symptoms
expected: Redirect to login/landing page after sign out
actual: Shows "Loading user information" indefinitely after clicking sign out
errors: No errors visible in console, UI, or network tab
reproduction: Just clicking the sign out button
started: Never worked - issue existed from first implementation

## Eliminated

## Evidence
- timestamp: 2026-04-01T00:00:00Z
checked: Sign out button location
found: Sign out button is in frontend/src/app/dashboard/settings/page.tsx line 61
implication: Button calls useAuth().signOut which comes from auth-provider

- timestamp: 2026-04-01T00:00:00Z
checked: Auth provider signOut implementation
found: frontend/src/components/providers/auth-provider.tsx lines 59-70 calls signOutUser() and clears localStorage
implication: No redirect logic after sign out - just clears auth state and storage

- timestamp: 2026-04-01T00:00:00Z
checked: Settings page behavior when user is null
found: frontend/src/app/dashboard/settings/page.tsx lines 10-19 shows "Loading user information..." when user is null
implication: After signOut, user becomes null, so page shows loading message instead of redirecting

- timestamp: 2026-04-01T00:00:00Z
checked: Middleware or route protection
found: No middleware.ts exists in frontend directory
implication: No automatic redirect for unauthenticated users accessing protected routes

- timestamp: 2026-04-01T00:00:00Z
checked: Navigation component sign out flow
found: frontend/src/modules/Home/components/Navigation.tsx lines 100-107 has handleSignOut that calls signOut() THEN router.push("/")
implication: Correct pattern exists - sign out then redirect. Settings page doesn't follow this pattern.

- timestamp: 2026-04-01T00:00:00Z
checked: Settings page sign out button
found: Settings page line 61 calls signOut directly without redirect
implication: ROOT CAUSE CONFIRMED: Missing redirect after signOut call

## Resolution
root_cause: The Settings page calls signOut() from auth context directly (line 61 of settings/page.tsx) without redirect logic. After signOut clears the auth state, the user becomes null, but the user stays on /dashboard/settings. The settings page shows "Loading user information..." when user is null (lines 10-19), creating the infinite loading state. The Navigation component correctly shows the pattern: call signOut() THEN redirect to "/" (Navigation.tsx lines 100-107). The Settings page is missing this redirect step.
fix: Added router.push("/") after signOut call in Settings page, matching the Navigation component pattern
verification: Sign out from Settings page redirects to landing page instead of showing infinite "Loading user information"
files_changed: [frontend/src/app/dashboard/settings/page.tsx]
22 changes: 12 additions & 10 deletions backend/api/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,30 @@

class Settings:
"""Application settings and configuration"""

# API Configuration
APP_TITLE: str = "QueryCraft API"
APP_DESCRIPTION: str = "Natural Language to SQL Platform API"
APP_VERSION: str = "1.0.0"

# CORS Configuration
CORS_ORIGINS: list = ["http://localhost:3000"]

# Clerk authentication
CLERK_JWKS_URL: str = os.getenv("CLERK_JWKS_URL", "")
CLERK_ISSUER: str = os.getenv("CLERK_ISSUER", "")

# Firebase authentication
FIREBASE_CREDENTIALS_PATH: str = os.getenv("FIREBASE_CREDENTIALS_PATH", "")
Comment on lines +21 to +22
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIREBASE_CREDENTIALS_PATH is now required for Firebase Admin auth, but backend/.env.example still documents only Clerk auth variables. Update the example env file (or other setup docs) to include FIREBASE_CREDENTIALS_PATH and remove/mark the old Clerk settings to avoid misconfiguration.

Suggested change
# Firebase authentication
FIREBASE_CREDENTIALS_PATH: str = os.getenv("FIREBASE_CREDENTIALS_PATH", "")
# Firebase authentication (required)
FIREBASE_CREDENTIALS_PATH: str = os.environ["FIREBASE_CREDENTIALS_PATH"]

Copilot uses AI. Check for mistakes.

# Paths
BASE_DIR: str = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CORE_PATH: str = os.path.join(BASE_DIR, 'core')
UPLOADS_DIR: str = os.path.join(BASE_DIR, 'uploads')

BASE_DIR: str = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
CORE_PATH: str = os.path.join(BASE_DIR, "core")
UPLOADS_DIR: str = os.path.join(BASE_DIR, "uploads")

@classmethod
def setup_paths(cls):
"""Add core and database packages to Python path for older entry points"""
import sys

if os.path.exists(cls.CORE_PATH) and cls.CORE_PATH not in sys.path:
sys.path.insert(0, cls.CORE_PATH)
if os.path.exists(cls.BASE_DIR) and cls.BASE_DIR not in sys.path:
Expand Down
83 changes: 47 additions & 36 deletions backend/api/middleware/auth.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Clerk JWT authentication middleware"""
"""Firebase JWT authentication middleware"""

import logging
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
from jwt import PyJWKClient
import firebase_admin
from firebase_admin import credentials, auth

from api.config.settings import settings

Expand All @@ -14,49 +14,60 @@
# HTTP Bearer token scheme (optional)
bearer_scheme = HTTPBearer(auto_error=False)

# Initialize Firebase Admin SDK
_firebase_initialized = False

def verify_clerk_token(token: str) -> dict:

def initialize_firebase():
"""Initialize Firebase Admin SDK with service account credentials."""
global _firebase_initialized
if not _firebase_initialized:
try:
cred = credentials.Certificate(settings.FIREBASE_CREDENTIALS_PATH)
firebase_admin.initialize_app(cred)
_firebase_initialized = True
logger.info("Firebase Admin SDK initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize Firebase Admin SDK: {str(e)}")
raise


# Initialize on module load
try:
if settings.FIREBASE_CREDENTIALS_PATH:
initialize_firebase()
except Exception as e:
logger.warning(f"Firebase initialization skipped: {str(e)}")


Comment on lines +36 to +42
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Firebase Admin initialization is effectively optional here (failures are caught and logged as "skipped"), but verify_firebase_token() will then fail all auth checks at runtime. If auth is required, consider failing fast on startup when FIREBASE_CREDENTIALS_PATH is missing/invalid (e.g., raise a clear configuration error) rather than starting an API that will return generic 401s for every request.

Suggested change
try:
if settings.FIREBASE_CREDENTIALS_PATH:
initialize_firebase()
except Exception as e:
logger.warning(f"Firebase initialization skipped: {str(e)}")
if not settings.FIREBASE_CREDENTIALS_PATH:
logger.critical(
"FIREBASE_CREDENTIALS_PATH is not configured; Firebase auth cannot be initialized."
)
raise RuntimeError("Missing FIREBASE_CREDENTIALS_PATH configuration")
initialize_firebase()

Copilot uses AI. Check for mistakes.
def verify_firebase_token(token: str) -> dict:
"""
Verify Clerk JWT token using JWKS endpoint.
Verify Firebase ID token using Firebase Admin SDK.

Args:
token: JWT token string
token: Firebase ID token string

Returns:
Decoded token payload containing user_id (sub), email, etc.
Decoded token payload containing uid, email, etc.

Raises:
HTTPException: If token is invalid or expired
"""
try:
# Initialize JWKS client with Clerk's public keys
jwks_client = PyJWKClient(settings.CLERK_JWKS_URL)

# Get the signing key from the token header
signing_key = jwks_client.get_signing_key_from_jwt(token)

# Decode and verify the token
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=settings.CLERK_ISSUER,
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_iss": True,
},
)
# Verify the ID token with small clock skew tolerance.
# This avoids intermittent "Token used too early" errors when
# local machine time and Firebase time differ by a few seconds.
decoded_token = auth.verify_id_token(token, clock_skew_seconds=10)

return payload
# Return the decoded token with uid
return decoded_token

except jwt.ExpiredSignatureError:
except auth.ExpiredIdTokenError:
logger.warning("Token expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired"
)
except jwt.InvalidTokenError as e:
except auth.InvalidIdTokenError as e:
logger.warning(f"Invalid token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
Expand All @@ -79,20 +90,20 @@ async def get_current_user(
Usage:
@router.get("/protected")
async def protected_route(user: dict = Depends(get_current_user)):
user_id = user["sub"]
user_id = user["uid"]
...

Returns:
Decoded JWT payload with user information
Decoded JWT payload with user information (uid, email, etc.)
Comment on lines 90 to +97
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says "Updated all API routes to require authentication (get_current_user)", but current routers still depend on get_optional_user and appear to reference Clerk's sub claim (e.g., backend/api/routers/databases.py:14-26, queries.py:15-43). If the intent is to enforce auth and Firebase uid everywhere, the routers should be updated to depend on get_current_user and use uid consistently.

Copilot uses AI. Check for mistakes.
"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication token",
)

payload = verify_clerk_token(credentials.credentials)
logger.info(f"Authenticated user: {payload.get('sub')}")
payload = verify_firebase_token(credentials.credentials)
logger.info(f"Authenticated user: {payload.get('uid')}")
return payload


Expand All @@ -107,7 +118,7 @@ async def get_optional_user(
@router.get("/public-or-private")
async def flexible_route(user: Optional[dict] = Depends(get_optional_user)):
if user:
user_id = user["sub"]
user_id = user["uid"]
# Return personalized data
else:
# Return public data
Expand All @@ -119,8 +130,8 @@ async def flexible_route(user: Optional[dict] = Depends(get_optional_user)):
return None

try:
payload = verify_clerk_token(credentials.credentials)
logger.info(f"Optional auth - authenticated user: {payload.get('sub')}")
payload = verify_firebase_token(credentials.credentials)
logger.info(f"Optional auth - authenticated user: {payload.get('uid')}")
return payload
except HTTPException:
# Token invalid - return None instead of raising error
Expand Down
60 changes: 43 additions & 17 deletions backend/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,37 @@
Database models for QueryCraft
SQLAlchemy models for PostgreSQL
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, BigInteger, Numeric, ForeignKey, JSON, Index

from sqlalchemy import (
Column,
Integer,
String,
Text,
Boolean,
DateTime,
BigInteger,
Numeric,
ForeignKey,
JSON,
Index,
)
from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy.sql import func
from datetime import UTC

Base = declarative_base()


class Database(Base):
"""Model for user-uploaded databases"""
__tablename__ = 'databases'

__tablename__ = "databases"
__table_args__ = (
Index('ix_databases_last_accessed', 'last_accessed'),
Index('ix_databases_is_active', 'is_active'),
Index("ix_databases_last_accessed", "last_accessed"),
Index("ix_databases_is_active", "is_active"),
Index("ix_databases_user_id", "user_id"),
)
Comment on lines +29 to 34
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user_id is indexed twice: __table_args__ defines Index("ix_databases_user_id", "user_id") and the column also sets index=True. This can create duplicate index objects (and may error during create_all / migrations). Keep only one of these (either the explicit Index or index=True).

Copilot uses AI. Check for mistakes.

id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False, unique=True)
display_name = Column(String(255), nullable=False)
Expand All @@ -28,11 +44,19 @@ class Database(Base):
table_count = Column(Integer, default=0)
row_count = Column(BigInteger, default=0)
size_mb = Column(Numeric(10, 2))
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
last_accessed = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
last_accessed = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
last_queried = Column(DateTime(timezone=True), nullable=True)
is_active = Column(Boolean, default=True)

user_id = Column(String(128), nullable=True, index=True)

# Relationships
query_history = relationship(
"QueryHistory",
Expand All @@ -42,26 +66,28 @@ class Database(Base):
)





class QueryHistory(Base):
"""Model for query execution history"""
__tablename__ = 'query_history'

__tablename__ = "query_history"
__table_args__ = (
Index('ix_query_history_db_created', 'database_id', 'created_at'),
Index("ix_query_history_db_created", "database_id", "created_at"),
Index("ix_query_history_user_id", "user_id"),
)
Comment on lines +72 to 76
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same duplicate-index issue for query_history.user_id: it is declared in __table_args__ and user_id = Column(..., index=True). Remove one of the index declarations to avoid duplicate index creation/errors.

Copilot uses AI. Check for mistakes.

id = Column(Integer, primary_key=True, index=True)
database_id = Column(Integer, ForeignKey('databases.id', ondelete='CASCADE'))
database_id = Column(Integer, ForeignKey("databases.id", ondelete="CASCADE"))
question = Column(Text, nullable=False)
sql_query = Column(Text, nullable=True)
execution_time_ms = Column(Integer, nullable=True)
result_count = Column(Integer, nullable=True, default=0)
confidence_score = Column(Integer, nullable=True)
success = Column(Boolean, default=True, nullable=False)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
user_id = Column(String(128), nullable=True, index=True)

# Relationship
database = relationship("Database", back_populates="query_history", lazy="joined")
Loading
Loading