Paramjit Singh commited on
Commit
f618282
·
unverified ·
2 Parent(s): 78168c36685ac4

Merge pull request #206 from UthkarshMandloi/feature/auth-huggingface

Browse files
.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 or API key."""
103
- token = credentials.credentials
 
 
 
 
 
 
 
 
 
 
 
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, Body, Depends, HTTPException, status
 
 
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
- await route.fulfill({ json: user });
 
 
 
 
 
 
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.getByRole("button", { name: "Sign In" }).click();
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
- // Auth guard
82
  useEffect(() => {
83
- if (!loading && !user) router.replace("/login");
84
- }, [user, loading, router]);
85
 
86
- // Intercept dashboard if Hugging Face token configuration is missing
87
  useEffect(() => {
88
  if (user) {
89
- const existingHfToken = localStorage.getItem("hf_token");
90
 
91
- if (!existingHfToken) {
92
- console.warn("Hugging Face API configuration key missing.");
 
 
93
  }
94
  }
95
  }, [user]);
@@ -153,7 +155,7 @@ export default function DashboardPage() {
153
  return () => clearInterval(interval);
154
  }, [documents, loadDocuments]);
155
 
156
- if (loading || !user) {
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
- <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
 
 
 
 
 
 
 
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
- return await fetch(input, init);
 
 
 
 
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
- if (!storedToken) {
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>("/api/v1/auth/me", { token: storedToken });
117
- set({ user, token: storedToken, loading: false, initialized: true });
 
 
 
 
 
 
 
 
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
- }