Spaces:
Running
Running
Merge pull request #206 from UthkarshMandloi/feature/auth-huggingface
Browse files- .env.example +8 -1
- README.md +4 -0
- backend/app/auth.py +17 -5
- backend/app/config.py +4 -0
- backend/app/routes/auth.py +206 -1
- backend/tests/test_auth.py +77 -0
- frontend/e2e/auth-and-chat.spec.ts +8 -2
- frontend/package-lock.json +1 -0
- frontend/src/app/dashboard/page.tsx +11 -9
- frontend/src/app/login/page.tsx +24 -4
- frontend/src/app/register/page.tsx +12 -3
- frontend/src/components/auth/HuggingFaceSignInButton.tsx +58 -0
- frontend/src/components/layout/Header.tsx +9 -1
- frontend/src/lib/api.ts +6 -2
- frontend/src/store/auth-store.ts +17 -9
- package-lock.json +0 -6
.env.example
CHANGED
|
@@ -69,13 +69,20 @@ ALLOWED_ORIGINS=http://localhost:3000,http://localhost:7860
|
|
| 69 |
# Optional — defaults to "pdf,docx,txt,md"
|
| 70 |
# ALLOWED_EXTENSIONS=pdf,docx,txt,md
|
| 71 |
|
| 72 |
-
# ── HuggingFace (Required for LLM inference) ───────
|
| 73 |
|
| 74 |
# HuggingFace API token. Used to call the Inference API for LLM responses.
|
| 75 |
# Get yours: https://huggingface.co/settings/tokens (free tier available)
|
| 76 |
# Required (app won't generate answers without it)
|
| 77 |
HF_TOKEN=your_huggingface_token_here
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# ── LLM Configuration ───────────────────────────────────────
|
| 80 |
|
| 81 |
# HuggingFace model ID used for answer generation.
|
|
|
|
| 69 |
# Optional — defaults to "pdf,docx,txt,md"
|
| 70 |
# ALLOWED_EXTENSIONS=pdf,docx,txt,md
|
| 71 |
|
| 72 |
+
# ── HuggingFace (Required for LLM inference and OAuth) ───────
|
| 73 |
|
| 74 |
# HuggingFace API token. Used to call the Inference API for LLM responses.
|
| 75 |
# Get yours: https://huggingface.co/settings/tokens (free tier available)
|
| 76 |
# Required (app won't generate answers without it)
|
| 77 |
HF_TOKEN=your_huggingface_token_here
|
| 78 |
|
| 79 |
+
# HuggingFace OAuth variables for native login support
|
| 80 |
+
# Optional — required only for Hugging Face sign-in
|
| 81 |
+
HF_CLIENT_ID=your_hf_oauth_client_id
|
| 82 |
+
HF_CLIENT_SECRET=your_hf_oauth_client_secret
|
| 83 |
+
HF_REDIRECT_URI=http://localhost:8000/api/v1/auth/callback/huggingface
|
| 84 |
+
FRONTEND_URL=http://localhost:3000
|
| 85 |
+
|
| 86 |
# ── LLM Configuration ───────────────────────────────────────
|
| 87 |
|
| 88 |
# HuggingFace model ID used for answer generation.
|
README.md
CHANGED
|
@@ -491,6 +491,10 @@ docker compose up --build
|
|
| 491 |
|---|---|---|---|---|
|
| 492 |
| `SECRET_KEY` | ✅ | — | JWT signing & session secret. Use a strong random string. | Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
| 493 |
| `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference via Inference API. | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (free) |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
| `ENVIRONMENT` | ❌ | `development` | Runtime mode. Set to `production` for deployment to lock CORS. | — |
|
| 495 |
| `DEBUG` | ❌ | `False` | Enable debug mode with detailed error pages. Never enable in production. | — |
|
| 496 |
| `ALLOWED_ORIGINS` | ❌ | `http://localhost:3000,http://localhost:7860` | Comma-separated CORS origins (only enforced in production). | Your deployed domain(s) |
|
|
|
|
| 491 |
|---|---|---|---|---|
|
| 492 |
| `SECRET_KEY` | ✅ | — | JWT signing & session secret. Use a strong random string. | Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
|
| 493 |
| `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference via Inference API. | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (free) |
|
| 494 |
+
| `HF_CLIENT_ID` | ❌ | — | HuggingFace OAuth client ID. Required only for Hugging Face sign-in. | [HuggingFace Developer Settings](https://huggingface.co/settings/connected-applications) |
|
| 495 |
+
| `HF_CLIENT_SECRET` | ❌ | — | HuggingFace OAuth client secret. Required only for Hugging Face sign-in. | [HuggingFace Developer Settings](https://huggingface.co/settings/connected-applications) |
|
| 496 |
+
| `HF_REDIRECT_URI` | ❌ | `http://localhost:8000/api/v1/auth/callback/huggingface` | HuggingFace OAuth callback redirect URI. | — |
|
| 497 |
+
| `FRONTEND_URL` | ❌ | `http://localhost:3000` | Frontend URL to redirect to after OAuth callback finishes. | — |
|
| 498 |
| `ENVIRONMENT` | ❌ | `development` | Runtime mode. Set to `production` for deployment to lock CORS. | — |
|
| 499 |
| `DEBUG` | ❌ | `False` | Enable debug mode with detailed error pages. Never enable in production. | — |
|
| 500 |
| `ALLOWED_ORIGINS` | ❌ | `http://localhost:3000,http://localhost:7860` | Comma-separated CORS origins (only enforced in production). | Your deployed domain(s) |
|
backend/app/auth.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import Optional, Any
|
|
| 6 |
|
| 7 |
import jwt
|
| 8 |
import bcrypt
|
| 9 |
-
from fastapi import Depends, HTTPException, status
|
| 10 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 11 |
from sqlalchemy.orm import Session
|
| 12 |
|
|
@@ -15,7 +15,7 @@ from app.database import get_db
|
|
| 15 |
from app.models import User, UserRole
|
| 16 |
|
| 17 |
settings = get_settings()
|
| 18 |
-
security = HTTPBearer()
|
| 19 |
|
| 20 |
|
| 21 |
# ── Password Hashing ─────────────────────────────────
|
|
@@ -96,11 +96,23 @@ def decode_invite_token(token: str) -> Optional[dict[str, Any]]:
|
|
| 96 |
import hashlib
|
| 97 |
|
| 98 |
def get_current_user(
|
| 99 |
-
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
|
|
| 100 |
db: Session = Depends(get_db),
|
| 101 |
) -> User:
|
| 102 |
-
"""Dependency: extract and validate user from JWT bearer token
|
| 103 |
-
token =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
# Check if token is an API key
|
| 106 |
if token.startswith("pdf_rag_"):
|
|
|
|
| 6 |
|
| 7 |
import jwt
|
| 8 |
import bcrypt
|
| 9 |
+
from fastapi import Depends, HTTPException, status, Cookie
|
| 10 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 11 |
from sqlalchemy.orm import Session
|
| 12 |
|
|
|
|
| 15 |
from app.models import User, UserRole
|
| 16 |
|
| 17 |
settings = get_settings()
|
| 18 |
+
security = HTTPBearer(auto_error=False)
|
| 19 |
|
| 20 |
|
| 21 |
# ── Password Hashing ─────────────────────────────────
|
|
|
|
| 96 |
import hashlib
|
| 97 |
|
| 98 |
def get_current_user(
|
| 99 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 100 |
+
access_token: Optional[str] = Cookie(None),
|
| 101 |
db: Session = Depends(get_db),
|
| 102 |
) -> User:
|
| 103 |
+
"""Dependency: extract and validate user from JWT bearer token, API key, or secure cookie."""
|
| 104 |
+
token = None
|
| 105 |
+
if credentials:
|
| 106 |
+
token = credentials.credentials
|
| 107 |
+
elif access_token:
|
| 108 |
+
token = access_token
|
| 109 |
+
|
| 110 |
+
if not token:
|
| 111 |
+
raise HTTPException(
|
| 112 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 113 |
+
detail="Invalid or expired token",
|
| 114 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 115 |
+
)
|
| 116 |
|
| 117 |
# Check if token is an API key
|
| 118 |
if token.startswith("pdf_rag_"):
|
backend/app/config.py
CHANGED
|
@@ -23,6 +23,10 @@ class Settings(BaseSettings):
|
|
| 23 |
JWT_ACCESS_EXPIRY_MINUTES: int = 15
|
| 24 |
JWT_REFRESH_EXPIRY_DAYS: int = 7
|
| 25 |
GOOGLE_CLIENT_ID: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# Google Drive background sync
|
| 28 |
DRIVE_SYNC_ENABLED: bool = False
|
|
|
|
| 23 |
JWT_ACCESS_EXPIRY_MINUTES: int = 15
|
| 24 |
JWT_REFRESH_EXPIRY_DAYS: int = 7
|
| 25 |
GOOGLE_CLIENT_ID: str = ""
|
| 26 |
+
HF_CLIENT_ID: str = ""
|
| 27 |
+
HF_CLIENT_SECRET: str = ""
|
| 28 |
+
HF_REDIRECT_URI: str = ""
|
| 29 |
+
FRONTEND_URL: str = "http://localhost:3000"
|
| 30 |
|
| 31 |
# Google Drive background sync
|
| 32 |
DRIVE_SYNC_ENABLED: bool = False
|
backend/app/routes/auth.py
CHANGED
|
@@ -3,8 +3,11 @@ Auth API routes — register, login, and user profile.
|
|
| 3 |
"""
|
| 4 |
import re
|
| 5 |
import secrets
|
|
|
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
-
from fastapi import APIRouter,
|
|
|
|
|
|
|
| 8 |
from langsmith import expect
|
| 9 |
from sqlalchemy.exc import SQLAlchemyError
|
| 10 |
from sqlalchemy.orm import Session
|
|
@@ -479,3 +482,205 @@ def get_auth_config():
|
|
| 479 |
return {
|
| 480 |
"google_client_id": settings.GOOGLE_CLIENT_ID
|
| 481 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import re
|
| 5 |
import secrets
|
| 6 |
+
from typing import Optional
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Cookie, Response, Body
|
| 9 |
+
from fastapi.responses import RedirectResponse
|
| 10 |
+
import httpx
|
| 11 |
from langsmith import expect
|
| 12 |
from sqlalchemy.exc import SQLAlchemyError
|
| 13 |
from sqlalchemy.orm import Session
|
|
|
|
| 482 |
return {
|
| 483 |
"google_client_id": settings.GOOGLE_CLIENT_ID
|
| 484 |
}
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def _unique_google_username(email: str, db: Session) -> str:
|
| 488 |
+
"""
|
| 489 |
+
Generate a unique username based on the email.
|
| 490 |
+
"""
|
| 491 |
+
base = email.split("@")[0]
|
| 492 |
+
base = re.sub(r"[^a-zA-Z0-9_-]", "", base)
|
| 493 |
+
base = base[:70]
|
| 494 |
+
candidate = base
|
| 495 |
+
suffix = 1
|
| 496 |
+
|
| 497 |
+
while db.query(User).filter(User.username == candidate).first():
|
| 498 |
+
suffix += 1
|
| 499 |
+
suffix_text = f"-{suffix}"
|
| 500 |
+
candidate = f"{base[:80 - len(suffix_text)]}{suffix_text}"
|
| 501 |
+
|
| 502 |
+
return candidate
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
@router.get("/login/huggingface")
|
| 506 |
+
def huggingface_login(response: Response):
|
| 507 |
+
"""
|
| 508 |
+
Generates a secure state, stores it in an HttpOnly cookie,
|
| 509 |
+
and returns the Hugging Face OAuth authorization URL.
|
| 510 |
+
"""
|
| 511 |
+
if not settings.HF_CLIENT_ID or not settings.HF_REDIRECT_URI:
|
| 512 |
+
raise HTTPException(
|
| 513 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 514 |
+
detail="Hugging Face OAuth is not configured",
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
# Generate CSRF state
|
| 518 |
+
state = secrets.token_urlsafe(32)
|
| 519 |
+
|
| 520 |
+
# Store state in cookie (valid for 10 minutes)
|
| 521 |
+
response.set_cookie(
|
| 522 |
+
key="oauth_state",
|
| 523 |
+
value=state,
|
| 524 |
+
httponly=True,
|
| 525 |
+
secure=settings.ENVIRONMENT == "production",
|
| 526 |
+
samesite="lax",
|
| 527 |
+
max_age=600, # 10 minutes
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
# Build Hugging Face authorize URL
|
| 531 |
+
scope = "openid profile email"
|
| 532 |
+
auth_url = (
|
| 533 |
+
f"https://huggingface.co/oauth/authorize?"
|
| 534 |
+
f"client_id={settings.HF_CLIENT_ID}&"
|
| 535 |
+
f"redirect_uri={settings.HF_REDIRECT_URI}&"
|
| 536 |
+
f"scope={scope}&"
|
| 537 |
+
f"state={state}&"
|
| 538 |
+
f"response_type=code"
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
return {"url": auth_url}
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
@router.get("/callback/huggingface")
|
| 545 |
+
async def huggingface_callback(
|
| 546 |
+
code: str,
|
| 547 |
+
state: str,
|
| 548 |
+
response: Response,
|
| 549 |
+
oauth_state: Optional[str] = Cookie(None),
|
| 550 |
+
db: Session = Depends(get_db),
|
| 551 |
+
):
|
| 552 |
+
"""
|
| 553 |
+
Verifies state, exchanges code for access token,
|
| 554 |
+
gets user info, upserts user, sets HttpOnly JWT cookies,
|
| 555 |
+
and redirects to the frontend dashboard.
|
| 556 |
+
"""
|
| 557 |
+
# 1. Verify CSRF State
|
| 558 |
+
if not oauth_state or state != oauth_state:
|
| 559 |
+
raise HTTPException(
|
| 560 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 561 |
+
detail="State verification failed. Possible CSRF attack.",
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
# 2. Exchange code for access_token via Hugging Face API
|
| 565 |
+
token_url = "https://huggingface.co/oauth/token"
|
| 566 |
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 567 |
+
data = {
|
| 568 |
+
"grant_type": "authorization_code",
|
| 569 |
+
"code": code,
|
| 570 |
+
"redirect_uri": settings.HF_REDIRECT_URI,
|
| 571 |
+
"client_id": settings.HF_CLIENT_ID,
|
| 572 |
+
"client_secret": settings.HF_CLIENT_SECRET,
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
async with httpx.AsyncClient() as client:
|
| 576 |
+
try:
|
| 577 |
+
token_response = await client.post(token_url, headers=headers, data=data)
|
| 578 |
+
token_response.raise_for_status()
|
| 579 |
+
token_data = token_response.json()
|
| 580 |
+
except httpx.HTTPStatusError as e:
|
| 581 |
+
raise HTTPException(
|
| 582 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 583 |
+
detail=f"Failed to exchange code: {e.response.text}",
|
| 584 |
+
)
|
| 585 |
+
except Exception as e:
|
| 586 |
+
raise HTTPException(
|
| 587 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 588 |
+
detail=f"Token exchange error: {str(e)}",
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
hf_access_token = token_data.get("access_token")
|
| 592 |
+
if not hf_access_token:
|
| 593 |
+
raise HTTPException(
|
| 594 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 595 |
+
detail="No access token returned from Hugging Face",
|
| 596 |
+
)
|
| 597 |
+
|
| 598 |
+
# 3. Fetch user profile data via /oauth/userinfo
|
| 599 |
+
userinfo_url = "https://huggingface.co/oauth/userinfo"
|
| 600 |
+
userinfo_headers = {"Authorization": f"Bearer {hf_access_token}"}
|
| 601 |
+
|
| 602 |
+
async with httpx.AsyncClient() as client:
|
| 603 |
+
try:
|
| 604 |
+
userinfo_response = await client.get(userinfo_url, headers=userinfo_headers)
|
| 605 |
+
userinfo_response.raise_for_status()
|
| 606 |
+
user_data = userinfo_response.json()
|
| 607 |
+
except Exception as e:
|
| 608 |
+
raise HTTPException(
|
| 609 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 610 |
+
detail=f"Failed to retrieve Hugging Face user info: {str(e)}",
|
| 611 |
+
)
|
| 612 |
+
|
| 613 |
+
email = user_data.get("email")
|
| 614 |
+
username = user_data.get("preferred_username") or user_data.get("username") or user_data.get("name")
|
| 615 |
+
|
| 616 |
+
if not email:
|
| 617 |
+
raise HTTPException(
|
| 618 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 619 |
+
detail="Hugging Face account email is required but not provided",
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
email = email.lower()
|
| 623 |
+
if not username:
|
| 624 |
+
username = email.split("@")[0]
|
| 625 |
+
|
| 626 |
+
# 4. Upsert user in the DB
|
| 627 |
+
user = db.query(User).filter(User.email == email).first()
|
| 628 |
+
if not user:
|
| 629 |
+
# Check if username is already taken
|
| 630 |
+
username = _unique_google_username(email, db)
|
| 631 |
+
user = User(
|
| 632 |
+
username=username,
|
| 633 |
+
email=email,
|
| 634 |
+
hashed_password=hash_password(secrets.token_urlsafe(32)),
|
| 635 |
+
)
|
| 636 |
+
db.add(user)
|
| 637 |
+
db.commit()
|
| 638 |
+
db.refresh(user)
|
| 639 |
+
|
| 640 |
+
user.last_login = datetime.now(timezone.utc)
|
| 641 |
+
db.commit()
|
| 642 |
+
db.refresh(user)
|
| 643 |
+
|
| 644 |
+
# 5. Generate secure session JWT tokens for our app
|
| 645 |
+
access_token = create_access_token(user.id)
|
| 646 |
+
refresh_token = create_refresh_token(user.id)
|
| 647 |
+
|
| 648 |
+
# 6. Set tokens as HttpOnly cookies and Redirect
|
| 649 |
+
redirect_dest = f"{settings.FRONTEND_URL}/dashboard" if settings.ENVIRONMENT == "development" else "/dashboard"
|
| 650 |
+
response = RedirectResponse(
|
| 651 |
+
url=redirect_dest,
|
| 652 |
+
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
response.set_cookie(
|
| 656 |
+
key="access_token",
|
| 657 |
+
value=access_token,
|
| 658 |
+
httponly=True,
|
| 659 |
+
secure=settings.ENVIRONMENT == "production",
|
| 660 |
+
samesite="lax",
|
| 661 |
+
max_age=settings.JWT_ACCESS_EXPIRY_MINUTES * 60,
|
| 662 |
+
)
|
| 663 |
+
|
| 664 |
+
response.set_cookie(
|
| 665 |
+
key="refresh_token",
|
| 666 |
+
value=refresh_token,
|
| 667 |
+
httponly=True,
|
| 668 |
+
secure=settings.ENVIRONMENT == "production",
|
| 669 |
+
samesite="lax",
|
| 670 |
+
max_age=settings.JWT_REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
# Delete the oauth_state cookie
|
| 674 |
+
response.delete_cookie(key="oauth_state")
|
| 675 |
+
|
| 676 |
+
return response
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
@router.post("/logout")
|
| 680 |
+
def logout(response: Response):
|
| 681 |
+
"""
|
| 682 |
+
Logs out the user by clearing the secure session cookies.
|
| 683 |
+
"""
|
| 684 |
+
response.delete_cookie(key="access_token")
|
| 685 |
+
response.delete_cookie(key="refresh_token")
|
| 686 |
+
return {"message": "Successfully logged out"}
|
backend/tests/test_auth.py
CHANGED
|
@@ -122,3 +122,80 @@ def test_hf_token_appears_in_user_response(client, auth_headers, user, db_sessio
|
|
| 122 |
stored_token = row[0]
|
| 123 |
assert stored_token is not None
|
| 124 |
assert stored_token != "hf_persist_token"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
stored_token = row[0]
|
| 123 |
assert stored_token is not None
|
| 124 |
assert stored_token != "hf_persist_token"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
from unittest.mock import patch, AsyncMock, MagicMock
|
| 128 |
+
import urllib.parse
|
| 129 |
+
|
| 130 |
+
def test_huggingface_login(client):
|
| 131 |
+
from app.config import get_settings
|
| 132 |
+
settings = get_settings()
|
| 133 |
+
settings.HF_CLIENT_ID = "test-client-id"
|
| 134 |
+
settings.HF_REDIRECT_URI = "http://localhost:8000/api/v1/auth/callback/huggingface"
|
| 135 |
+
|
| 136 |
+
response = client.get("/api/v1/auth/login/huggingface")
|
| 137 |
+
assert response.status_code == 200
|
| 138 |
+
data = response.json()
|
| 139 |
+
assert "url" in data
|
| 140 |
+
assert "test-client-id" in data["url"]
|
| 141 |
+
assert "oauth_state" in response.cookies
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@patch("httpx.AsyncClient.post")
|
| 145 |
+
@patch("httpx.AsyncClient.get")
|
| 146 |
+
def test_huggingface_callback_success(mock_get, mock_post, client):
|
| 147 |
+
from app.config import get_settings
|
| 148 |
+
settings = get_settings()
|
| 149 |
+
settings.HF_CLIENT_ID = "test-client-id"
|
| 150 |
+
settings.HF_CLIENT_SECRET = "test-client-secret"
|
| 151 |
+
settings.HF_REDIRECT_URI = "http://localhost:8000/api/v1/auth/callback/huggingface"
|
| 152 |
+
|
| 153 |
+
mock_post_resp = MagicMock()
|
| 154 |
+
mock_post_resp.status_code = 200
|
| 155 |
+
mock_post_resp.json.return_value = {"access_token": "hf-access-token"}
|
| 156 |
+
mock_post.return_value = mock_post_resp
|
| 157 |
+
|
| 158 |
+
mock_get_resp = MagicMock()
|
| 159 |
+
mock_get_resp.status_code = 200
|
| 160 |
+
mock_get_resp.json.return_value = {
|
| 161 |
+
"email": "hfuser@example.com",
|
| 162 |
+
"preferred_username": "hfuser"
|
| 163 |
+
}
|
| 164 |
+
mock_get.return_value = mock_get_resp
|
| 165 |
+
|
| 166 |
+
login_response = client.get("/api/v1/auth/login/huggingface")
|
| 167 |
+
state_cookie = login_response.cookies["oauth_state"]
|
| 168 |
+
url = login_response.json()["url"]
|
| 169 |
+
parsed = urllib.parse.urlparse(url)
|
| 170 |
+
queries = urllib.parse.parse_qs(parsed.query)
|
| 171 |
+
state_param = queries["state"][0]
|
| 172 |
+
|
| 173 |
+
client.cookies.set("oauth_state", state_cookie)
|
| 174 |
+
callback_response = client.get(
|
| 175 |
+
f"/api/v1/auth/callback/huggingface?code=hf-code&state={state_param}",
|
| 176 |
+
follow_redirects=False
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
assert callback_response.status_code == 307
|
| 180 |
+
assert "/dashboard" in callback_response.headers["location"]
|
| 181 |
+
assert "access_token" in callback_response.cookies
|
| 182 |
+
assert "refresh_token" in callback_response.cookies
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_huggingface_callback_invalid_state(client):
|
| 186 |
+
response = client.get(
|
| 187 |
+
"/api/v1/auth/callback/huggingface?code=hf-code&state=invalid-state",
|
| 188 |
+
cookies={"oauth_state": "actual-state"}
|
| 189 |
+
)
|
| 190 |
+
assert response.status_code == 400
|
| 191 |
+
assert "State verification failed" in response.json()["detail"]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_huggingface_logout(client):
|
| 195 |
+
response = client.post(
|
| 196 |
+
"/api/v1/auth/logout",
|
| 197 |
+
cookies={"access_token": "token-value", "refresh_token": "refresh-value"}
|
| 198 |
+
)
|
| 199 |
+
assert response.status_code == 200
|
| 200 |
+
assert response.cookies.get("access_token") in (None, "")
|
| 201 |
+
assert response.cookies.get("refresh_token") in (None, "")
|
frontend/e2e/auth-and-chat.spec.ts
CHANGED
|
@@ -28,7 +28,13 @@ const uploadedDocument = {
|
|
| 28 |
|
| 29 |
async function mockDashboardApis(page: Page, documents: typeof uploadedDocument[] = []) {
|
| 30 |
await page.route("**/api/v1/auth/me", async (route) => {
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
});
|
| 33 |
|
| 34 |
await page.route("**/api/v1/documents/", async (route) => {
|
|
@@ -54,7 +60,7 @@ test("logs in with email and password", async ({ page }) => {
|
|
| 54 |
await page.goto("/login");
|
| 55 |
await page.locator("#login-email").fill(user.email);
|
| 56 |
await page.locator("#login-password").fill("password123");
|
| 57 |
-
await page.
|
| 58 |
|
| 59 |
await expect(page).toHaveURL(/\/dashboard$/);
|
| 60 |
await expect(page.getByText("No documents yet")).toBeVisible();
|
|
|
|
| 28 |
|
| 29 |
async function mockDashboardApis(page: Page, documents: typeof uploadedDocument[] = []) {
|
| 30 |
await page.route("**/api/v1/auth/me", async (route) => {
|
| 31 |
+
const headers = route.request().headers();
|
| 32 |
+
const hasAuth = headers["authorization"] || headers["cookie"];
|
| 33 |
+
if (hasAuth) {
|
| 34 |
+
await route.fulfill({ json: user });
|
| 35 |
+
} else {
|
| 36 |
+
await route.fulfill({ status: 401, json: { detail: "Not authenticated" } });
|
| 37 |
+
}
|
| 38 |
});
|
| 39 |
|
| 40 |
await page.route("**/api/v1/documents/", async (route) => {
|
|
|
|
| 60 |
await page.goto("/login");
|
| 61 |
await page.locator("#login-email").fill(user.email);
|
| 62 |
await page.locator("#login-password").fill("password123");
|
| 63 |
+
await page.locator("#sign-in-btn").click();
|
| 64 |
|
| 65 |
await expect(page).toHaveURL(/\/dashboard$/);
|
| 66 |
await expect(page.getByText("No documents yet")).toBeVisible();
|
frontend/package-lock.json
CHANGED
|
@@ -5699,6 +5699,7 @@
|
|
| 5699 |
"version": "2.3.2",
|
| 5700 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 5701 |
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
|
|
| 5702 |
"hasInstallScript": true,
|
| 5703 |
"license": "MIT",
|
| 5704 |
"optional": true,
|
|
|
|
| 5699 |
"version": "2.3.2",
|
| 5700 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 5701 |
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
| 5702 |
+
"dev": true,
|
| 5703 |
"hasInstallScript": true,
|
| 5704 |
"license": "MIT",
|
| 5705 |
"optional": true,
|
frontend/src/app/dashboard/page.tsx
CHANGED
|
@@ -56,7 +56,7 @@ export interface DocInfo {
|
|
| 56 |
}
|
| 57 |
|
| 58 |
export default function DashboardPage() {
|
| 59 |
-
const { user, loading } = useAuth();
|
| 60 |
const router = useRouter();
|
| 61 |
|
| 62 |
const [documents, setDocuments] = useState<DocInfo[]>([]);
|
|
@@ -78,18 +78,20 @@ export default function DashboardPage() {
|
|
| 78 |
const [connectionError, setConnectionError] = useState("");
|
| 79 |
const [documentsLoading, setDocumentsLoading] = useState(true);
|
| 80 |
|
| 81 |
-
|
| 82 |
useEffect(() => {
|
| 83 |
-
if (
|
| 84 |
-
}, [user,
|
| 85 |
|
| 86 |
-
//
|
| 87 |
useEffect(() => {
|
| 88 |
if (user) {
|
| 89 |
-
const
|
| 90 |
|
| 91 |
-
if (!
|
| 92 |
-
console.
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
}
|
| 95 |
}, [user]);
|
|
@@ -153,7 +155,7 @@ export default function DashboardPage() {
|
|
| 153 |
return () => clearInterval(interval);
|
| 154 |
}, [documents, loadDocuments]);
|
| 155 |
|
| 156 |
-
if (
|
| 157 |
return (
|
| 158 |
<div className="min-h-screen flex items-center justify-center">
|
| 159 |
<div className="animate-pulse-glow w-12 h-12 rounded-full bg-primary/20" />
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
export default function DashboardPage() {
|
| 59 |
+
const { user, loading, initialized } = useAuth();
|
| 60 |
const router = useRouter();
|
| 61 |
|
| 62 |
const [documents, setDocuments] = useState<DocInfo[]>([]);
|
|
|
|
| 78 |
const [connectionError, setConnectionError] = useState("");
|
| 79 |
const [documentsLoading, setDocumentsLoading] = useState(true);
|
| 80 |
|
| 81 |
+
// Auth guard
|
| 82 |
useEffect(() => {
|
| 83 |
+
if (initialized && !user) router.replace("/login");
|
| 84 |
+
}, [user, initialized, router]);
|
| 85 |
|
| 86 |
+
// Check if Hugging Face token configuration is present
|
| 87 |
useEffect(() => {
|
| 88 |
if (user) {
|
| 89 |
+
const hasHfToken = !!(user.hf_token || localStorage.getItem("hf_token"));
|
| 90 |
|
| 91 |
+
if (!hasHfToken) {
|
| 92 |
+
console.info(
|
| 93 |
+
"Hugging Face API token is not configured. Personal model access will fall back to the system default unless set in the user profile menu."
|
| 94 |
+
);
|
| 95 |
}
|
| 96 |
}
|
| 97 |
}, [user]);
|
|
|
|
| 155 |
return () => clearInterval(interval);
|
| 156 |
}, [documents, loadDocuments]);
|
| 157 |
|
| 158 |
+
if (!initialized || !user) {
|
| 159 |
return (
|
| 160 |
<div className="min-h-screen flex items-center justify-center">
|
| 161 |
<div className="animate-pulse-glow w-12 h-12 rounded-full bg-primary/20" />
|
frontend/src/app/login/page.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useCallback, useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
import { useTranslation } from "react-i18next";
|
|
@@ -10,9 +10,10 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|
| 10 |
import { Brain, Eye, EyeOff } from "lucide-react";
|
| 11 |
import Link from "next/link";
|
| 12 |
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
|
|
|
| 13 |
|
| 14 |
export default function LoginPage() {
|
| 15 |
-
const { login } = useAuth();
|
| 16 |
const { t } = useTranslation();
|
| 17 |
const router = useRouter();
|
| 18 |
const [email, setEmail] = useState("");
|
|
@@ -21,6 +22,13 @@ export default function LoginPage() {
|
|
| 21 |
const [error, setError] = useState("");
|
| 22 |
const [loading, setLoading] = useState(false);
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const handleGoogleSuccess = useCallback(() => {
|
| 25 |
router.replace("/dashboard");
|
| 26 |
}, [router]);
|
|
@@ -58,13 +66,25 @@ export default function LoginPage() {
|
|
| 58 |
</CardHeader>
|
| 59 |
|
| 60 |
<CardContent>
|
| 61 |
-
<div className="mb-4">
|
|
|
|
| 62 |
<GoogleSignInButton
|
| 63 |
onError={setError}
|
| 64 |
onSuccess={handleGoogleSuccess}
|
| 65 |
/>
|
| 66 |
</div>
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 69 |
{error && (
|
| 70 |
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
|
@@ -107,7 +127,7 @@ export default function LoginPage() {
|
|
| 107 |
</div>
|
| 108 |
</div>
|
| 109 |
|
| 110 |
-
<Button type="submit" className="w-full h-11 text-base" disabled={loading}>
|
| 111 |
{loading ? (
|
| 112 |
<span className="flex items-center gap-2">
|
| 113 |
<span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useCallback, useState, useEffect } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
import { useTranslation } from "react-i18next";
|
|
|
|
| 10 |
import { Brain, Eye, EyeOff } from "lucide-react";
|
| 11 |
import Link from "next/link";
|
| 12 |
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
| 13 |
+
import HuggingFaceSignInButton from "@/components/auth/HuggingFaceSignInButton";
|
| 14 |
|
| 15 |
export default function LoginPage() {
|
| 16 |
+
const { login, user, initialized } = useAuth();
|
| 17 |
const { t } = useTranslation();
|
| 18 |
const router = useRouter();
|
| 19 |
const [email, setEmail] = useState("");
|
|
|
|
| 22 |
const [error, setError] = useState("");
|
| 23 |
const [loading, setLoading] = useState(false);
|
| 24 |
|
| 25 |
+
// Redirect if already logged in
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
if (initialized && user) {
|
| 28 |
+
router.replace("/dashboard");
|
| 29 |
+
}
|
| 30 |
+
}, [user, initialized, router]);
|
| 31 |
+
|
| 32 |
const handleGoogleSuccess = useCallback(() => {
|
| 33 |
router.replace("/dashboard");
|
| 34 |
}, [router]);
|
|
|
|
| 66 |
</CardHeader>
|
| 67 |
|
| 68 |
<CardContent>
|
| 69 |
+
<div className="flex flex-col gap-2.5 mb-4">
|
| 70 |
+
<HuggingFaceSignInButton onError={setError} />
|
| 71 |
<GoogleSignInButton
|
| 72 |
onError={setError}
|
| 73 |
onSuccess={handleGoogleSuccess}
|
| 74 |
/>
|
| 75 |
</div>
|
| 76 |
|
| 77 |
+
<div className="relative my-5">
|
| 78 |
+
<div className="absolute inset-0 flex items-center">
|
| 79 |
+
<span className="w-full border-t border-border/40" />
|
| 80 |
+
</div>
|
| 81 |
+
<div className="relative flex justify-center text-xs uppercase">
|
| 82 |
+
<span className="bg-card px-2.5 text-muted-foreground text-[10px] tracking-wider font-semibold">
|
| 83 |
+
Or continue with
|
| 84 |
+
</span>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 89 |
{error && (
|
| 90 |
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
|
|
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
|
| 130 |
+
<Button id="sign-in-btn" type="submit" className="w-full h-11 text-base" disabled={loading}>
|
| 131 |
{loading ? (
|
| 132 |
<span className="flex items-center gap-2">
|
| 133 |
<span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
frontend/src/app/register/page.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useCallback, useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
import { useTranslation } from "react-i18next";
|
|
@@ -10,9 +10,10 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|
| 10 |
import { Brain, Eye, EyeOff } from "lucide-react";
|
| 11 |
import Link from "next/link";
|
| 12 |
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
|
|
|
| 13 |
|
| 14 |
export default function RegisterPage() {
|
| 15 |
-
const { register } = useAuth();
|
| 16 |
const { t } = useTranslation();
|
| 17 |
const router = useRouter();
|
| 18 |
const [username, setUsername] = useState("");
|
|
@@ -22,6 +23,13 @@ export default function RegisterPage() {
|
|
| 22 |
const [error, setError] = useState("");
|
| 23 |
const [loading, setLoading] = useState(false);
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
const handleGoogleSuccess = useCallback(() => {
|
| 26 |
router.replace("/dashboard");
|
| 27 |
}, [router]);
|
|
@@ -58,7 +66,8 @@ export default function RegisterPage() {
|
|
| 58 |
</CardHeader>
|
| 59 |
|
| 60 |
<CardContent>
|
| 61 |
-
<div className="mb-4">
|
|
|
|
| 62 |
<GoogleSignInButton
|
| 63 |
onError={setError}
|
| 64 |
onSuccess={handleGoogleSuccess}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useCallback, useState, useEffect } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
import { useTranslation } from "react-i18next";
|
|
|
|
| 10 |
import { Brain, Eye, EyeOff } from "lucide-react";
|
| 11 |
import Link from "next/link";
|
| 12 |
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
| 13 |
+
import HuggingFaceSignInButton from "@/components/auth/HuggingFaceSignInButton";
|
| 14 |
|
| 15 |
export default function RegisterPage() {
|
| 16 |
+
const { register, user, initialized } = useAuth();
|
| 17 |
const { t } = useTranslation();
|
| 18 |
const router = useRouter();
|
| 19 |
const [username, setUsername] = useState("");
|
|
|
|
| 23 |
const [error, setError] = useState("");
|
| 24 |
const [loading, setLoading] = useState(false);
|
| 25 |
|
| 26 |
+
// Redirect if already logged in
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (initialized && user) {
|
| 29 |
+
router.replace("/dashboard");
|
| 30 |
+
}
|
| 31 |
+
}, [user, initialized, router]);
|
| 32 |
+
|
| 33 |
const handleGoogleSuccess = useCallback(() => {
|
| 34 |
router.replace("/dashboard");
|
| 35 |
}, [router]);
|
|
|
|
| 66 |
</CardHeader>
|
| 67 |
|
| 68 |
<CardContent>
|
| 69 |
+
<div className="flex flex-col gap-2.5 mb-4">
|
| 70 |
+
<HuggingFaceSignInButton onError={setError} />
|
| 71 |
<GoogleSignInButton
|
| 72 |
onError={setError}
|
| 73 |
onSuccess={handleGoogleSuccess}
|
frontend/src/components/auth/HuggingFaceSignInButton.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { api } from "@/lib/api";
|
| 6 |
+
|
| 7 |
+
type HuggingFaceSignInButtonProps = {
|
| 8 |
+
onError: (message: string) => void;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export default function HuggingFaceSignInButton({ onError }: HuggingFaceSignInButtonProps) {
|
| 12 |
+
const [loading, setLoading] = useState(false);
|
| 13 |
+
|
| 14 |
+
const handleLogin = async () => {
|
| 15 |
+
setLoading(true);
|
| 16 |
+
try {
|
| 17 |
+
// 1. Fetch the Hugging Face OAuth authorization URL from backend
|
| 18 |
+
const data = await api.get<{ url: string }>("/api/v1/auth/login/huggingface");
|
| 19 |
+
if (data.url) {
|
| 20 |
+
// 2. Redirect the user's browser to Hugging Face
|
| 21 |
+
window.location.href = data.url;
|
| 22 |
+
} else {
|
| 23 |
+
onError("Could not retrieve authorization URL from backend.");
|
| 24 |
+
setLoading(false);
|
| 25 |
+
}
|
| 26 |
+
} catch (error) {
|
| 27 |
+
onError(
|
| 28 |
+
error instanceof Error
|
| 29 |
+
? error.message
|
| 30 |
+
: "An error occurred while connecting to Hugging Face OAuth."
|
| 31 |
+
);
|
| 32 |
+
setLoading(false);
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<Button
|
| 38 |
+
onClick={handleLogin}
|
| 39 |
+
disabled={loading}
|
| 40 |
+
variant="outline"
|
| 41 |
+
className="w-full h-11 bg-card/45 backdrop-blur-md border border-border/60 hover:border-[#FFD21E]/60 hover:bg-[#FFD21E]/5 hover:shadow-[0_0_15px_-3px_rgba(255,210,30,0.18)] text-foreground hover:text-[#FFD21E] transition-all duration-300 shadow-sm relative group flex items-center justify-center gap-2.5 font-semibold rounded-xl overflow-hidden active:scale-[0.98] cursor-pointer"
|
| 42 |
+
>
|
| 43 |
+
{loading ? (
|
| 44 |
+
<span className="w-5 h-5 border-2 border-[#FFD21E]/30 border-t-[#FFD21E] rounded-full animate-spin mr-1" />
|
| 45 |
+
) : (
|
| 46 |
+
<svg
|
| 47 |
+
className="w-5 h-5 transition-transform duration-300 group-hover:scale-110 fill-current text-[#FFD21E]"
|
| 48 |
+
viewBox="0 0 24 24"
|
| 49 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 50 |
+
>
|
| 51 |
+
<title>Hugging Face</title>
|
| 52 |
+
<path d="M12.025 1.13c-5.77 0-10.449 4.647-10.449 10.378 0 1.112.178 2.181.503 3.185.064-.222.203-.444.416-.577a.96.96 0 0 1 .524-.15c.293 0 .584.124.84.284.278.173.48.408.71.694.226.282.458.611.684.951v-.014c.017-.324.106-.622.264-.874s.403-.487.762-.543c.3-.047.596.06.787.203s.31.313.4.467c.15.257.212.468.233.542.01.026.653 1.552 1.657 2.54.616.605 1.01 1.223 1.082 1.912.055.537-.096 1.059-.38 1.572.637.121 1.294.187 1.967.187.657 0 1.298-.063 1.921-.178-.287-.517-.44-1.041-.384-1.581.07-.69.465-1.307 1.081-1.913 1.004-.987 1.647-2.513 1.657-2.539.021-.074.083-.285.233-.542.09-.154.208-.323.4-.467a1.08 1.08 0 0 1 .787-.203c.359.056.604.29.762.543s.247.55.265.874v.015c.225-.34.457-.67.683-.952.23-.286.432-.52.71-.694.257-.16.547-.284.84-.285a.97.97 0 0 1 .524.151c.228.143.373.388.43.625l.006.04a10.3 10.3 0 0 0 .534-3.273c0-5.731-4.678-10.378-10.449-10.378M8.327 6.583a1.5 1.5 0 0 1 .713.174 1.487 1.487 0 0 1 .617 2.013c-.183.343-.762-.214-1.102-.094-.38.134-.532.914-.917.71a1.487 1.487 0 0 1 .69-2.803m7.486 0a1.487 1.487 0 0 1 .689 2.803c-.385.204-.536-.576-.916-.71-.34-.12-.92.437-1.103.094a1.487 1.487 0 0 1 .617-2.013 1.5 1.5 0 0 1 .713-.174m-10.68 1.55a.96.96 0 1 1 0 1.921.96.96 0 0 1 0-1.92m13.838 0a.96.96 0 1 1 0 1.92.96.96 0 0 1 0-1.92M8.489 11.458c.588.01 1.965 1.157 3.572 1.164 1.607-.007 2.984-1.155 3.572-1.164.196-.003.305.12.305.454 0 .886-.424 2.328-1.563 3.202-.22-.756-1.396-1.366-1.63-1.32q-.011.001-.02.006l-.044.026-.01.008-.03.024q-.018.017-.035.036l-.032.04a1 1 0 0 0-.058.09l-.014.025q-.049.088-.11.19a1 1 0 0 1-.083.116 1.2 1.2 0 0 1-.173.18q-.035.029-.075.058a1.3 1.3 0 0 1-.251-.243 1 1 0 0 1-.076-.107c-.124-.193-.177-.363-.337-.444-.034-.016-.104-.008-.2.022q-.094.03-.216.087-.06.028-.125.063l-.13.074q-.067.04-.136.086a3 3 0 0 0-.135.096 3 3 0 0 0-.26.219 2 2 0 0 0-.12.121 2 2 0 0 0-.106.128l-.002.002a2 2 0 0 0-.09.132l-.001.001a1.2 1.2 0 0 0-.105.212q-.013.036-.024.073c-1.139-.875-1.563-2.317-1.563-3.203 0-.334.109-.457.305-.454m.836 10.354c.824-1.19.766-2.082-.365-3.194-1.13-1.112-1.789-2.738-1.789-2.738s-.246-.945-.806-.858-.97 1.499.202 2.362c1.173.864-.233 1.45-.685.64-.45-.812-1.683-2.896-2.322-3.295s-1.089-.175-.938.647 2.822 2.813 2.562 3.244-1.176-.506-1.176-.506-2.866-2.567-3.49-1.898.473 1.23 2.037 2.16c1.564.932 1.686 1.178 1.464 1.53s-3.675-2.511-4-1.297c-.323 1.214 3.524 1.567 3.287 2.405-.238.839-2.71-1.587-3.216-.642-.506.946 3.49 2.056 3.522 2.064 1.29.33 4.568 1.028 5.713-.624m5.349 0c-.824-1.19-.766-2.082.365-3.194 1.13-1.112 1.789-2.738 1.789-2.738s.246-.945.806-.858.97 1.499-.202 2.362c-1.173.864.233 1.45.685.64.451-.812 1.683-2.896 2.322-3.295s1.089-.175.938.647-2.822 2.813-2.562 3.244 1.176-.506 1.176-.506 2.866-2.567 3.49-1.898-.473 1.23-2.037 2.16c-1.564.932-1.686 1.178-1.464 1.53s3.675-2.511 4-1.297c.323 1.214-3.524 1.567-3.287 2.405.238.839 2.71-1.587 3.216-.642.506.946-3.49 2.056-3.522 2.064-1.29.33-4.568 1.028-5.713-.624" />
|
| 53 |
+
</svg>
|
| 54 |
+
)}
|
| 55 |
+
<span className="truncate">Sign in with Hugging Face</span>
|
| 56 |
+
</Button>
|
| 57 |
+
);
|
| 58 |
+
}
|
frontend/src/components/layout/Header.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
| 37 |
import { useWorkspaceStore, WORKSPACES, type WorkspaceId } from "@/store/workspace-store";
|
| 38 |
import { api } from "@/lib/api";
|
| 39 |
import { useTheme } from "next-themes";
|
|
|
|
| 40 |
|
| 41 |
import { useSyncExternalStore } from "react";
|
| 42 |
|
|
@@ -223,7 +224,14 @@ export default function Header({
|
|
| 223 |
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
| 224 |
</div>
|
| 225 |
<DropdownMenuSeparator />
|
| 226 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
<LogOut className="w-4 h-4 mr-2" />
|
| 228 |
Sign out
|
| 229 |
</DropdownMenuItem>
|
|
|
|
| 37 |
import { useWorkspaceStore, WORKSPACES, type WorkspaceId } from "@/store/workspace-store";
|
| 38 |
import { api } from "@/lib/api";
|
| 39 |
import { useTheme } from "next-themes";
|
| 40 |
+
import HuggingFaceTokenModal from "@/components/auth/HuggingFaceTokenModal";
|
| 41 |
|
| 42 |
import { useSyncExternalStore } from "react";
|
| 43 |
|
|
|
|
| 224 |
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
| 225 |
</div>
|
| 226 |
<DropdownMenuSeparator />
|
| 227 |
+
<div className="px-1 py-0.5">
|
| 228 |
+
<HuggingFaceTokenModal />
|
| 229 |
+
</div>
|
| 230 |
+
<DropdownMenuSeparator />
|
| 231 |
+
<DropdownMenuItem
|
| 232 |
+
className="text-destructive cursor-pointer"
|
| 233 |
+
onClick={handleLogout}
|
| 234 |
+
>
|
| 235 |
<LogOut className="w-4 h-4 mr-2" />
|
| 236 |
Sign out
|
| 237 |
</DropdownMenuItem>
|
frontend/src/lib/api.ts
CHANGED
|
@@ -39,7 +39,7 @@ class ApiClient {
|
|
| 39 |
};
|
| 40 |
|
| 41 |
const authToken = token || this.getToken();
|
| 42 |
-
if (authToken) {
|
| 43 |
headers["Authorization"] = `Bearer ${authToken}`;
|
| 44 |
}
|
| 45 |
|
|
@@ -48,7 +48,11 @@ class ApiClient {
|
|
| 48 |
|
| 49 |
private async fetchWithConnectionError(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
| 50 |
try {
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
} catch (error) {
|
| 53 |
if (error instanceof TypeError) {
|
| 54 |
throw new Error(CONNECTION_ERROR_MESSAGE);
|
|
|
|
| 39 |
};
|
| 40 |
|
| 41 |
const authToken = token || this.getToken();
|
| 42 |
+
if (authToken && authToken !== "cookie") {
|
| 43 |
headers["Authorization"] = `Bearer ${authToken}`;
|
| 44 |
}
|
| 45 |
|
|
|
|
| 48 |
|
| 49 |
private async fetchWithConnectionError(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
| 50 |
try {
|
| 51 |
+
const mergedInit = {
|
| 52 |
+
credentials: "include" as const,
|
| 53 |
+
...init,
|
| 54 |
+
};
|
| 55 |
+
return await fetch(input, mergedInit);
|
| 56 |
} catch (error) {
|
| 57 |
if (error instanceof TypeError) {
|
| 58 |
throw new Error(CONNECTION_ERROR_MESSAGE);
|
frontend/src/store/auth-store.ts
CHANGED
|
@@ -90,7 +90,12 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
|
|
| 90 |
});
|
| 91 |
},
|
| 92 |
|
| 93 |
-
logout() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
clearStoredTokens();
|
| 95 |
set({
|
| 96 |
token: null,
|
|
@@ -105,16 +110,19 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
|
|
| 105 |
if (initialized) return;
|
| 106 |
|
| 107 |
const storedToken = token ?? getStoredToken();
|
| 108 |
-
|
| 109 |
-
set({ token: null, user: null, loading: false, initialized: true });
|
| 110 |
-
return;
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
set({ token: storedToken, loading: true });
|
| 114 |
|
| 115 |
try {
|
| 116 |
-
const user = await api.get<AuthUser>(
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
} catch {
|
| 119 |
clearStoredTokens();
|
| 120 |
set({ user: null, token: null, loading: false, initialized: true });
|
|
|
|
| 90 |
});
|
| 91 |
},
|
| 92 |
|
| 93 |
+
async logout() {
|
| 94 |
+
try {
|
| 95 |
+
await api.post("/api/v1/auth/logout");
|
| 96 |
+
} catch {
|
| 97 |
+
// Ignore network errors on logout
|
| 98 |
+
}
|
| 99 |
clearStoredTokens();
|
| 100 |
set({
|
| 101 |
token: null,
|
|
|
|
| 110 |
if (initialized) return;
|
| 111 |
|
| 112 |
const storedToken = token ?? getStoredToken();
|
| 113 |
+
set({ loading: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
try {
|
| 116 |
+
const user = await api.get<AuthUser>(
|
| 117 |
+
"/api/v1/auth/me",
|
| 118 |
+
storedToken ? { token: storedToken } : undefined
|
| 119 |
+
);
|
| 120 |
+
set({
|
| 121 |
+
user,
|
| 122 |
+
token: storedToken || "cookie",
|
| 123 |
+
loading: false,
|
| 124 |
+
initialized: true,
|
| 125 |
+
});
|
| 126 |
} catch {
|
| 127 |
clearStoredTokens();
|
| 128 |
set({ user: null, token: null, loading: false, initialized: true });
|
package-lock.json
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "PDF-Assistant-RAG",
|
| 3 |
-
"lockfileVersion": 3,
|
| 4 |
-
"requires": true,
|
| 5 |
-
"packages": {}
|
| 6 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|