Testla24 commited on
Commit
0326bb1
·
1 Parent(s): 758f79c

feat(auth): Add Hugging Face OAuth Login (#168)

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
@@ -469,6 +469,10 @@ docker compose up --build
469
  |---|---|---|---|---|
470
  | `SECRET_KEY` | ✅ | — | JWT signing & session secret. Use a strong random string. | Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
471
  | `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference via Inference API. | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (free) |
 
 
 
 
472
  | `ENVIRONMENT` | ❌ | `development` | Runtime mode. Set to `production` for deployment to lock CORS. | — |
473
  | `DEBUG` | ❌ | `False` | Enable debug mode with detailed error pages. Never enable in production. | — |
474
  | `ALLOWED_ORIGINS` | ❌ | `http://localhost:3000,http://localhost:7860` | Comma-separated CORS origins (only enforced in production). | Your deployed domain(s) |
 
469
  |---|---|---|---|---|
470
  | `SECRET_KEY` | ✅ | — | JWT signing & session secret. Use a strong random string. | Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
471
  | `HF_TOKEN` | ✅ | — | HuggingFace API token for LLM inference via Inference API. | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (free) |
472
+ | `HF_CLIENT_ID` | ❌ | — | HuggingFace OAuth client ID. Required only for Hugging Face sign-in. | [HuggingFace Developer Settings](https://huggingface.co/settings/connected-applications) |
473
+ | `HF_CLIENT_SECRET` | ❌ | — | HuggingFace OAuth client secret. Required only for Hugging Face sign-in. | [HuggingFace Developer Settings](https://huggingface.co/settings/connected-applications) |
474
+ | `HF_REDIRECT_URI` | ❌ | `http://localhost:8000/api/v1/auth/callback/huggingface` | HuggingFace OAuth callback redirect URI. | — |
475
+ | `FRONTEND_URL` | ❌ | `http://localhost:3000` | Frontend URL to redirect to after OAuth callback finishes. | — |
476
  | `ENVIRONMENT` | ❌ | `development` | Runtime mode. Set to `production` for deployment to lock CORS. | — |
477
  | `DEBUG` | ❌ | `False` | Enable debug mode with detailed error pages. Never enable in production. | — |
478
  | `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
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
16
 
17
  settings = get_settings()
18
- security = HTTPBearer()
19
 
20
 
21
  # ── Password Hashing ─────────────────────────────────
@@ -70,11 +70,23 @@ def decode_token(token: str, token_type: str = "access") -> Optional[str]:
70
  import hashlib
71
 
72
  def get_current_user(
73
- credentials: HTTPAuthorizationCredentials = Depends(security),
 
74
  db: Session = Depends(get_db),
75
  ) -> User:
76
- """Dependency: extract and validate user from JWT bearer token or API key."""
77
- token = credentials.credentials
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  # Check if token is an API key
80
  if token.startswith("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
16
 
17
  settings = get_settings()
18
+ security = HTTPBearer(auto_error=False)
19
 
20
 
21
  # ── Password Hashing ─────────────────────────────────
 
70
  import hashlib
71
 
72
  def get_current_user(
73
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
74
+ access_token: Optional[str] = Cookie(None),
75
  db: Session = Depends(get_db),
76
  ) -> User:
77
+ """Dependency: extract and validate user from JWT bearer token, API key, or secure cookie."""
78
+ token = None
79
+ if credentials:
80
+ token = credentials.credentials
81
+ elif access_token:
82
+ token = access_token
83
+
84
+ if not token:
85
+ raise HTTPException(
86
+ status_code=status.HTTP_401_UNAUTHORIZED,
87
+ detail="Invalid or expired token",
88
+ headers={"WWW-Authenticate": "Bearer"},
89
+ )
90
 
91
  # Check if token is an API key
92
  if token.startswith("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
  # ── File Upload ──────────────────────────────────────
28
  UPLOAD_DIR: str = "./data/uploads"
 
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
  # ── File Upload ──────────────────────────────────────
32
  UPLOAD_DIR: str = "./data/uploads"
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, Depends, HTTPException, status
 
 
8
  from langsmith import expect
9
  from sqlalchemy.exc import SQLAlchemyError
10
  from sqlalchemy.orm import Session
@@ -456,3 +459,205 @@ def get_auth_config():
456
  return {
457
  "google_client_id": settings.GOOGLE_CLIENT_ID
458
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
 
459
  return {
460
  "google_client_id": settings.GOOGLE_CLIENT_ID
461
  }
462
+
463
+
464
+ def _unique_google_username(email: str, db: Session) -> str:
465
+ """
466
+ Generate a unique username based on the email.
467
+ """
468
+ base = email.split("@")[0]
469
+ base = re.sub(r"[^a-zA-Z0-9_-]", "", base)
470
+ base = base[:70]
471
+ candidate = base
472
+ suffix = 1
473
+
474
+ while db.query(User).filter(User.username == candidate).first():
475
+ suffix += 1
476
+ suffix_text = f"-{suffix}"
477
+ candidate = f"{base[:80 - len(suffix_text)]}{suffix_text}"
478
+
479
+ return candidate
480
+
481
+
482
+ @router.get("/login/huggingface")
483
+ def huggingface_login(response: Response):
484
+ """
485
+ Generates a secure state, stores it in an HttpOnly cookie,
486
+ and returns the Hugging Face OAuth authorization URL.
487
+ """
488
+ if not settings.HF_CLIENT_ID or not settings.HF_REDIRECT_URI:
489
+ raise HTTPException(
490
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
491
+ detail="Hugging Face OAuth is not configured",
492
+ )
493
+
494
+ # Generate CSRF state
495
+ state = secrets.token_urlsafe(32)
496
+
497
+ # Store state in cookie (valid for 10 minutes)
498
+ response.set_cookie(
499
+ key="oauth_state",
500
+ value=state,
501
+ httponly=True,
502
+ secure=settings.ENVIRONMENT == "production",
503
+ samesite="lax",
504
+ max_age=600, # 10 minutes
505
+ )
506
+
507
+ # Build Hugging Face authorize URL
508
+ scope = "openid profile email"
509
+ auth_url = (
510
+ f"https://huggingface.co/oauth/authorize?"
511
+ f"client_id={settings.HF_CLIENT_ID}&"
512
+ f"redirect_uri={settings.HF_REDIRECT_URI}&"
513
+ f"scope={scope}&"
514
+ f"state={state}&"
515
+ f"response_type=code"
516
+ )
517
+
518
+ return {"url": auth_url}
519
+
520
+
521
+ @router.get("/callback/huggingface")
522
+ async def huggingface_callback(
523
+ code: str,
524
+ state: str,
525
+ response: Response,
526
+ oauth_state: Optional[str] = Cookie(None),
527
+ db: Session = Depends(get_db),
528
+ ):
529
+ """
530
+ Verifies state, exchanges code for access token,
531
+ gets user info, upserts user, sets HttpOnly JWT cookies,
532
+ and redirects to the frontend dashboard.
533
+ """
534
+ # 1. Verify CSRF State
535
+ if not oauth_state or state != oauth_state:
536
+ raise HTTPException(
537
+ status_code=status.HTTP_400_BAD_REQUEST,
538
+ detail="State verification failed. Possible CSRF attack.",
539
+ )
540
+
541
+ # 2. Exchange code for access_token via Hugging Face API
542
+ token_url = "https://huggingface.co/oauth/token"
543
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
544
+ data = {
545
+ "grant_type": "authorization_code",
546
+ "code": code,
547
+ "redirect_uri": settings.HF_REDIRECT_URI,
548
+ "client_id": settings.HF_CLIENT_ID,
549
+ "client_secret": settings.HF_CLIENT_SECRET,
550
+ }
551
+
552
+ async with httpx.AsyncClient() as client:
553
+ try:
554
+ token_response = await client.post(token_url, headers=headers, data=data)
555
+ token_response.raise_for_status()
556
+ token_data = token_response.json()
557
+ except httpx.HTTPStatusError as e:
558
+ raise HTTPException(
559
+ status_code=status.HTTP_401_UNAUTHORIZED,
560
+ detail=f"Failed to exchange code: {e.response.text}",
561
+ )
562
+ except Exception as e:
563
+ raise HTTPException(
564
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
565
+ detail=f"Token exchange error: {str(e)}",
566
+ )
567
+
568
+ hf_access_token = token_data.get("access_token")
569
+ if not hf_access_token:
570
+ raise HTTPException(
571
+ status_code=status.HTTP_401_UNAUTHORIZED,
572
+ detail="No access token returned from Hugging Face",
573
+ )
574
+
575
+ # 3. Fetch user profile data via /oauth/userinfo
576
+ userinfo_url = "https://huggingface.co/oauth/userinfo"
577
+ userinfo_headers = {"Authorization": f"Bearer {hf_access_token}"}
578
+
579
+ async with httpx.AsyncClient() as client:
580
+ try:
581
+ userinfo_response = await client.get(userinfo_url, headers=userinfo_headers)
582
+ userinfo_response.raise_for_status()
583
+ user_data = userinfo_response.json()
584
+ except Exception as e:
585
+ raise HTTPException(
586
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
587
+ detail=f"Failed to retrieve Hugging Face user info: {str(e)}",
588
+ )
589
+
590
+ email = user_data.get("email")
591
+ username = user_data.get("preferred_username") or user_data.get("username") or user_data.get("name")
592
+
593
+ if not email:
594
+ raise HTTPException(
595
+ status_code=status.HTTP_400_BAD_REQUEST,
596
+ detail="Hugging Face account email is required but not provided",
597
+ )
598
+
599
+ email = email.lower()
600
+ if not username:
601
+ username = email.split("@")[0]
602
+
603
+ # 4. Upsert user in the DB
604
+ user = db.query(User).filter(User.email == email).first()
605
+ if not user:
606
+ # Check if username is already taken
607
+ username = _unique_google_username(email, db)
608
+ user = User(
609
+ username=username,
610
+ email=email,
611
+ hashed_password=hash_password(secrets.token_urlsafe(32)),
612
+ )
613
+ db.add(user)
614
+ db.commit()
615
+ db.refresh(user)
616
+
617
+ user.last_login = datetime.now(timezone.utc)
618
+ db.commit()
619
+ db.refresh(user)
620
+
621
+ # 5. Generate secure session JWT tokens for our app
622
+ access_token = create_access_token(user.id)
623
+ refresh_token = create_refresh_token(user.id)
624
+
625
+ # 6. Set tokens as HttpOnly cookies and Redirect
626
+ redirect_dest = f"{settings.FRONTEND_URL}/dashboard" if settings.ENVIRONMENT == "development" else "/dashboard"
627
+ response = RedirectResponse(
628
+ url=redirect_dest,
629
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
630
+ )
631
+
632
+ response.set_cookie(
633
+ key="access_token",
634
+ value=access_token,
635
+ httponly=True,
636
+ secure=settings.ENVIRONMENT == "production",
637
+ samesite="lax",
638
+ max_age=settings.JWT_ACCESS_EXPIRY_MINUTES * 60,
639
+ )
640
+
641
+ response.set_cookie(
642
+ key="refresh_token",
643
+ value=refresh_token,
644
+ httponly=True,
645
+ secure=settings.ENVIRONMENT == "production",
646
+ samesite="lax",
647
+ max_age=settings.JWT_REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
648
+ )
649
+
650
+ # Delete the oauth_state cookie
651
+ response.delete_cookie(key="oauth_state")
652
+
653
+ return response
654
+
655
+
656
+ @router.post("/logout")
657
+ def logout(response: Response):
658
+ """
659
+ Logs out the user by clearing the secure session cookies.
660
+ """
661
+ response.delete_cookie(key="access_token")
662
+ response.delete_cookie(key="refresh_token")
663
+ 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/package-lock.json CHANGED
@@ -83,7 +83,6 @@
83
  "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
84
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
85
  "license": "MIT",
86
- "peer": true,
87
  "dependencies": {
88
  "@babel/code-frame": "^7.29.0",
89
  "@babel/generator": "^7.29.0",
@@ -677,7 +676,6 @@
677
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
678
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
679
  "license": "MIT",
680
- "peer": true,
681
  "engines": {
682
  "node": ">=12"
683
  },
@@ -2112,7 +2110,6 @@
2112
  "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
2113
  "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
2114
  "license": "MIT",
2115
- "peer": true,
2116
  "engines": {
2117
  "node": "^14.21.3 || >=16"
2118
  },
@@ -2220,7 +2217,6 @@
2220
  "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
2221
  "devOptional": true,
2222
  "license": "Apache-2.0",
2223
- "peer": true,
2224
  "dependencies": {
2225
  "playwright": "1.60.0"
2226
  },
@@ -2689,7 +2685,6 @@
2689
  "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
2690
  "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
2691
  "license": "MIT",
2692
- "peer": true,
2693
  "dependencies": {
2694
  "undici-types": "~6.21.0"
2695
  }
@@ -2699,7 +2694,6 @@
2699
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
2700
  "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
2701
  "license": "MIT",
2702
- "peer": true,
2703
  "dependencies": {
2704
  "csstype": "^3.2.2"
2705
  }
@@ -2786,7 +2780,6 @@
2786
  "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
2787
  "dev": true,
2788
  "license": "MIT",
2789
- "peer": true,
2790
  "dependencies": {
2791
  "@typescript-eslint/scope-manager": "8.59.0",
2792
  "@typescript-eslint/types": "8.59.0",
@@ -3331,7 +3324,6 @@
3331
  "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
3332
  "dev": true,
3333
  "license": "MIT",
3334
- "peer": true,
3335
  "bin": {
3336
  "acorn": "bin/acorn"
3337
  },
@@ -3786,7 +3778,6 @@
3786
  }
3787
  ],
3788
  "license": "MIT",
3789
- "peer": true,
3790
  "dependencies": {
3791
  "baseline-browser-mapping": "^2.10.12",
3792
  "caniuse-lite": "^1.0.30001782",
@@ -4838,7 +4829,6 @@
4838
  "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
4839
  "dev": true,
4840
  "license": "MIT",
4841
- "peer": true,
4842
  "dependencies": {
4843
  "@eslint-community/eslint-utils": "^4.8.0",
4844
  "@eslint-community/regexpp": "^4.12.1",
@@ -5024,7 +5014,6 @@
5024
  "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
5025
  "dev": true,
5026
  "license": "MIT",
5027
- "peer": true,
5028
  "dependencies": {
5029
  "@rtsao/scc": "^1.1.0",
5030
  "array-includes": "^3.1.9",
@@ -5324,7 +5313,6 @@
5324
  "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
5325
  "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
5326
  "license": "MIT",
5327
- "peer": true,
5328
  "dependencies": {
5329
  "accepts": "^2.0.0",
5330
  "body-parser": "^2.2.1",
@@ -6149,7 +6137,6 @@
6149
  "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
6150
  "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
6151
  "license": "MIT",
6152
- "peer": true,
6153
  "engines": {
6154
  "node": ">=16.9.0"
6155
  }
@@ -6234,7 +6221,6 @@
6234
  }
6235
  ],
6236
  "license": "MIT",
6237
- "peer": true,
6238
  "peerDependencies": {
6239
  "typescript": "^5 || ^6"
6240
  },
@@ -9591,7 +9577,6 @@
9591
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
9592
  "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
9593
  "license": "MIT",
9594
- "peer": true,
9595
  "engines": {
9596
  "node": ">=0.10.0"
9597
  }
@@ -9601,7 +9586,6 @@
9601
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
9602
  "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
9603
  "license": "MIT",
9604
- "peer": true,
9605
  "dependencies": {
9606
  "scheduler": "^0.27.0"
9607
  },
@@ -10926,7 +10910,6 @@
10926
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
10927
  "dev": true,
10928
  "license": "MIT",
10929
- "peer": true,
10930
  "engines": {
10931
  "node": ">=12"
10932
  },
@@ -11195,7 +11178,6 @@
11195
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
11196
  "devOptional": true,
11197
  "license": "Apache-2.0",
11198
- "peer": true,
11199
  "bin": {
11200
  "tsc": "bin/tsc",
11201
  "tsserver": "bin/tsserver"
@@ -11878,7 +11860,6 @@
11878
  "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
11879
  "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
11880
  "license": "MIT",
11881
- "peer": true,
11882
  "funding": {
11883
  "url": "https://github.com/sponsors/colinhacks"
11884
  }
 
83
  "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
84
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
85
  "license": "MIT",
 
86
  "dependencies": {
87
  "@babel/code-frame": "^7.29.0",
88
  "@babel/generator": "^7.29.0",
 
676
  "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
677
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
678
  "license": "MIT",
 
679
  "engines": {
680
  "node": ">=12"
681
  },
 
2110
  "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
2111
  "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
2112
  "license": "MIT",
 
2113
  "engines": {
2114
  "node": "^14.21.3 || >=16"
2115
  },
 
2217
  "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
2218
  "devOptional": true,
2219
  "license": "Apache-2.0",
 
2220
  "dependencies": {
2221
  "playwright": "1.60.0"
2222
  },
 
2685
  "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
2686
  "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
2687
  "license": "MIT",
 
2688
  "dependencies": {
2689
  "undici-types": "~6.21.0"
2690
  }
 
2694
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
2695
  "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
2696
  "license": "MIT",
 
2697
  "dependencies": {
2698
  "csstype": "^3.2.2"
2699
  }
 
2780
  "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
2781
  "dev": true,
2782
  "license": "MIT",
 
2783
  "dependencies": {
2784
  "@typescript-eslint/scope-manager": "8.59.0",
2785
  "@typescript-eslint/types": "8.59.0",
 
3324
  "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
3325
  "dev": true,
3326
  "license": "MIT",
 
3327
  "bin": {
3328
  "acorn": "bin/acorn"
3329
  },
 
3778
  }
3779
  ],
3780
  "license": "MIT",
 
3781
  "dependencies": {
3782
  "baseline-browser-mapping": "^2.10.12",
3783
  "caniuse-lite": "^1.0.30001782",
 
4829
  "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
4830
  "dev": true,
4831
  "license": "MIT",
 
4832
  "dependencies": {
4833
  "@eslint-community/eslint-utils": "^4.8.0",
4834
  "@eslint-community/regexpp": "^4.12.1",
 
5014
  "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
5015
  "dev": true,
5016
  "license": "MIT",
 
5017
  "dependencies": {
5018
  "@rtsao/scc": "^1.1.0",
5019
  "array-includes": "^3.1.9",
 
5313
  "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
5314
  "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
5315
  "license": "MIT",
 
5316
  "dependencies": {
5317
  "accepts": "^2.0.0",
5318
  "body-parser": "^2.2.1",
 
6137
  "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
6138
  "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
6139
  "license": "MIT",
 
6140
  "engines": {
6141
  "node": ">=16.9.0"
6142
  }
 
6221
  }
6222
  ],
6223
  "license": "MIT",
 
6224
  "peerDependencies": {
6225
  "typescript": "^5 || ^6"
6226
  },
 
9577
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
9578
  "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
9579
  "license": "MIT",
 
9580
  "engines": {
9581
  "node": ">=0.10.0"
9582
  }
 
9586
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
9587
  "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
9588
  "license": "MIT",
 
9589
  "dependencies": {
9590
  "scheduler": "^0.27.0"
9591
  },
 
10910
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
10911
  "dev": true,
10912
  "license": "MIT",
 
10913
  "engines": {
10914
  "node": ">=12"
10915
  },
 
11178
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
11179
  "devOptional": true,
11180
  "license": "Apache-2.0",
 
11181
  "bin": {
11182
  "tsc": "bin/tsc",
11183
  "tsserver": "bin/tsserver"
 
11860
  "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
11861
  "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
11862
  "license": "MIT",
 
11863
  "funding": {
11864
  "url": "https://github.com/sponsors/colinhacks"
11865
  }
frontend/src/app/dashboard/page.tsx CHANGED
@@ -53,7 +53,7 @@ export interface DocInfo {
53
  }
54
 
55
  export default function DashboardPage() {
56
- const { user, loading } = useAuth();
57
  const router = useRouter();
58
 
59
  const [documents, setDocuments] = useState<DocInfo[]>([]);
@@ -63,10 +63,10 @@ export default function DashboardPage() {
63
  const [viewerOpen, setViewerOpen] = useState(true);
64
  const [connectionError, setConnectionError] = useState("");
65
 
66
- // Auth guard
67
  useEffect(() => {
68
- if (!loading && !user) router.replace("/login");
69
- }, [user, loading, router]);
70
 
71
  // Intercept dashboard if Hugging Face token configuration is missing
72
  useEffect(() => {
@@ -116,7 +116,7 @@ export default function DashboardPage() {
116
  return () => clearInterval(interval);
117
  }, [documents, loadDocuments]);
118
 
119
- if (loading || !user) {
120
  return (
121
  <div className="min-h-screen flex items-center justify-center">
122
  <div className="animate-pulse-glow w-12 h-12 rounded-full bg-primary/20" />
 
53
  }
54
 
55
  export default function DashboardPage() {
56
+ const { user, loading, initialized } = useAuth();
57
  const router = useRouter();
58
 
59
  const [documents, setDocuments] = useState<DocInfo[]>([]);
 
63
  const [viewerOpen, setViewerOpen] = useState(true);
64
  const [connectionError, setConnectionError] = useState("");
65
 
66
+ // Auth guard
67
  useEffect(() => {
68
+ if (initialized && !user) router.replace("/login");
69
+ }, [user, initialized, router]);
70
 
71
  // Intercept dashboard if Hugging Face token configuration is missing
72
  useEffect(() => {
 
116
  return () => clearInterval(interval);
117
  }, [documents, loadDocuments]);
118
 
119
+ if (!initialized || !user) {
120
  return (
121
  <div className="min-h-screen flex items-center justify-center">
122
  <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">
 
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">
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/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);
@@ -201,29 +205,6 @@ class ApiClient {
201
  return res.json();
202
  }
203
 
204
- async put<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
205
- const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
206
- method: "PUT",
207
- headers: this.getHeaders(options?.token),
208
- body: body ? JSON.stringify(body) : undefined,
209
- ...options,
210
- });
211
-
212
- // Auto-refresh on 401
213
- if (res.status === 401 && !options?._skipRefresh) {
214
- const newToken = await this.tryRefreshToken();
215
- if (newToken) {
216
- return this.put<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
217
- }
218
- }
219
-
220
- if (!res.ok) {
221
- throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
222
- }
223
-
224
- return res.json();
225
- }
226
-
227
  async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
228
  const token = options?.token || this.getToken();
229
  const headers: HeadersInit = {};
 
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);
 
205
  return res.json();
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
209
  const token = options?.token || this.getToken();
210
  const headers: HeadersInit = {};
frontend/src/store/auth-store.ts CHANGED
@@ -8,7 +8,6 @@ export interface AuthUser {
8
  username: string;
9
  email: string;
10
  is_admin: boolean;
11
- hf_token?: string;
12
  created_at: string;
13
  }
14
 
@@ -24,7 +23,6 @@ interface AuthStore {
24
  initializeAuth: () => Promise<void>;
25
  syncTokensRefreshed: (detail?: { accessToken?: string; user?: AuthUser | null }) => void;
26
  syncLoggedOut: () => void;
27
- setHfToken: (hfToken: string) => Promise<void>;
28
  }
29
 
30
  const getStoredToken = () =>
@@ -90,7 +88,12 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
90
  });
91
  },
92
 
93
- logout() {
 
 
 
 
 
94
  clearStoredTokens();
95
  set({
96
  token: null,
@@ -105,16 +108,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 });
@@ -140,9 +146,4 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
140
  initialized: true,
141
  });
142
  },
143
-
144
- async setHfToken(hfToken: string) {
145
- const response = await api.put<AuthUser>("/api/v1/auth/hf-token", { hf_token: hfToken });
146
- set({ user: response });
147
- },
148
  }));
 
8
  username: string;
9
  email: string;
10
  is_admin: boolean;
 
11
  created_at: string;
12
  }
13
 
 
23
  initializeAuth: () => Promise<void>;
24
  syncTokensRefreshed: (detail?: { accessToken?: string; user?: AuthUser | null }) => void;
25
  syncLoggedOut: () => void;
 
26
  }
27
 
28
  const getStoredToken = () =>
 
88
  });
89
  },
90
 
91
+ async logout() {
92
+ try {
93
+ await api.post("/api/v1/auth/logout");
94
+ } catch {
95
+ // Ignore network errors on logout
96
+ }
97
  clearStoredTokens();
98
  set({
99
  token: null,
 
108
  if (initialized) return;
109
 
110
  const storedToken = token ?? getStoredToken();
111
+ set({ loading: true });
 
 
 
 
 
112
 
113
  try {
114
+ const user = await api.get<AuthUser>(
115
+ "/api/v1/auth/me",
116
+ storedToken ? { token: storedToken } : undefined
117
+ );
118
+ set({
119
+ user,
120
+ token: storedToken || "cookie",
121
+ loading: false,
122
+ initialized: true,
123
+ });
124
  } catch {
125
  clearStoredTokens();
126
  set({ user: null, token: null, loading: false, initialized: true });
 
146
  initialized: true,
147
  });
148
  },
 
 
 
 
 
149
  }));