rafmacalaba commited on
Commit
d140e69
·
1 Parent(s): d08736d

feat: HF OAuth login, increase docs to 50, filter consensus non-datasets

Browse files

- Add /api/auth/login (redirect to HF OAuth authorize)
- Add /api/auth/callback (exchange code, set hf_user cookie)
- Read hf_user cookie on mount for annotator identity
- Add top bar with 'Sign in with HF' button / username display
- Set hf_oauth: true in README metadata
- Increase MAX_DOCS_TO_SCAN from 5 to 50
- Filter out consensus non-datasets (model + judge agree)

README.md CHANGED
@@ -6,6 +6,8 @@ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
  app_port: 7860
 
 
9
  ---
10
 
11
  # Annotation MVP
 
6
  sdk: docker
7
  pinned: false
8
  app_port: 7860
9
+ hf_oauth: true
10
+ hf_oauth_expiration_minutes: 480
11
  ---
12
 
13
  # Annotation MVP
app/api/auth/callback/route.js ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * GET /api/auth/callback
5
+ * Handles the OAuth callback from HuggingFace.
6
+ * Exchanges code for access token, fetches userinfo, sets session cookie.
7
+ */
8
+ export async function GET(request) {
9
+ const { searchParams } = new URL(request.url);
10
+ const code = searchParams.get('code');
11
+ const state = searchParams.get('state');
12
+
13
+ if (!code) {
14
+ return NextResponse.json({ error: 'Missing code parameter' }, { status: 400 });
15
+ }
16
+
17
+ // Verify state
18
+ const savedState = request.cookies.get('oauth_state')?.value;
19
+ if (!savedState || savedState !== state) {
20
+ return NextResponse.json({ error: 'Invalid state parameter' }, { status: 400 });
21
+ }
22
+
23
+ const clientId = process.env.OAUTH_CLIENT_ID;
24
+ const clientSecret = process.env.OAUTH_CLIENT_SECRET;
25
+
26
+ if (!clientId || !clientSecret) {
27
+ return NextResponse.json({ error: 'OAuth not configured' }, { status: 500 });
28
+ }
29
+
30
+ const host = process.env.SPACE_HOST
31
+ ? `https://${process.env.SPACE_HOST}`
32
+ : 'http://localhost:3000';
33
+ const redirectUri = `${host}/api/auth/callback`;
34
+
35
+ try {
36
+ // Exchange code for access token
37
+ const tokenRes = await fetch('https://huggingface.co/oauth/token', {
38
+ method: 'POST',
39
+ headers: {
40
+ 'Content-Type': 'application/x-www-form-urlencoded',
41
+ 'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
42
+ },
43
+ body: new URLSearchParams({
44
+ grant_type: 'authorization_code',
45
+ code,
46
+ redirect_uri: redirectUri,
47
+ }),
48
+ });
49
+
50
+ if (!tokenRes.ok) {
51
+ const errText = await tokenRes.text();
52
+ console.error('Token exchange failed:', errText);
53
+ return NextResponse.json({ error: 'Failed to exchange code for token' }, { status: 500 });
54
+ }
55
+
56
+ const tokenData = await tokenRes.json();
57
+
58
+ // Fetch user info
59
+ const userRes = await fetch('https://huggingface.co/oauth/userinfo', {
60
+ headers: { 'Authorization': `Bearer ${tokenData.access_token}` },
61
+ });
62
+
63
+ if (!userRes.ok) {
64
+ return NextResponse.json({ error: 'Failed to fetch user info' }, { status: 500 });
65
+ }
66
+
67
+ const userInfo = await userRes.json();
68
+ const username = userInfo.preferred_username || userInfo.name || 'user';
69
+
70
+ // Set session cookie with username
71
+ const response = NextResponse.redirect(host);
72
+ response.cookies.set('hf_user', JSON.stringify({
73
+ username,
74
+ name: userInfo.name,
75
+ picture: userInfo.picture,
76
+ }), {
77
+ httpOnly: false, // readable by client JS
78
+ secure: process.env.NODE_ENV === 'production',
79
+ sameSite: 'lax',
80
+ maxAge: 60 * 60 * 8, // 8 hours
81
+ path: '/',
82
+ });
83
+
84
+ // Clear the state cookie
85
+ response.cookies.delete('oauth_state');
86
+
87
+ return response;
88
+ } catch (error) {
89
+ console.error('OAuth callback error:', error);
90
+ return NextResponse.json({ error: 'OAuth callback failed: ' + error.message }, { status: 500 });
91
+ }
92
+ }
app/api/auth/login/route.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import crypto from 'crypto';
3
+
4
+ /**
5
+ * GET /api/auth/login
6
+ * Redirects user to HuggingFace OAuth authorize URL.
7
+ */
8
+ export async function GET(request) {
9
+ const clientId = process.env.OAUTH_CLIENT_ID;
10
+ if (!clientId) {
11
+ return NextResponse.json(
12
+ { error: 'OAuth not configured (missing OAUTH_CLIENT_ID). Set hf_oauth: true in Space metadata.' },
13
+ { status: 500 }
14
+ );
15
+ }
16
+
17
+ // Build redirect URI
18
+ const host = process.env.SPACE_HOST
19
+ ? `https://${process.env.SPACE_HOST}`
20
+ : 'http://localhost:3000';
21
+ const redirectUri = `${host}/api/auth/callback`;
22
+
23
+ // Generate state for CSRF protection
24
+ const state = crypto.randomBytes(16).toString('hex');
25
+
26
+ const params = new URLSearchParams({
27
+ client_id: clientId,
28
+ redirect_uri: redirectUri,
29
+ scope: 'openid profile',
30
+ response_type: 'code',
31
+ state: state,
32
+ });
33
+
34
+ const authorizeUrl = `https://huggingface.co/oauth/authorize?${params.toString()}`;
35
+
36
+ // Set state in a cookie for verification on callback
37
+ const response = NextResponse.redirect(authorizeUrl);
38
+ response.cookies.set('oauth_state', state, {
39
+ httpOnly: true,
40
+ secure: true,
41
+ sameSite: 'lax',
42
+ maxAge: 300, // 5 minutes
43
+ path: '/',
44
+ });
45
+
46
+ return response;
47
+ }
app/globals.css CHANGED
@@ -35,11 +35,71 @@ h4 {
35
 
36
  /* ── Layout ─────────────────────────────────────── */
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  .container {
39
  display: flex;
40
  width: 100%;
41
- height: 100%;
42
  flex-wrap: wrap;
 
43
  }
44
 
45
  .pane {
 
35
 
36
  /* ── Layout ─────────────────────────────────────── */
37
 
38
+ /* ── App Wrapper & Top Bar ────────────────────── */
39
+
40
+ .app-wrapper {
41
+ display: flex;
42
+ flex-direction: column;
43
+ width: 100%;
44
+ height: 100vh;
45
+ }
46
+
47
+ .top-bar {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ padding: 6px 20px;
52
+ background-color: var(--pane-bg);
53
+ border-bottom: 1px solid var(--border-color);
54
+ flex-shrink: 0;
55
+ height: 40px;
56
+ }
57
+
58
+ .top-bar-title {
59
+ font-weight: 700;
60
+ font-size: 0.85rem;
61
+ text-transform: uppercase;
62
+ letter-spacing: 0.08em;
63
+ color: var(--accent);
64
+ }
65
+
66
+ .top-bar-user {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 8px;
70
+ }
71
+
72
+ .user-badge {
73
+ font-size: 0.8rem;
74
+ font-weight: 600;
75
+ color: var(--success);
76
+ background: rgba(16, 185, 129, 0.1);
77
+ padding: 3px 10px;
78
+ border-radius: 12px;
79
+ border: 1px solid rgba(16, 185, 129, 0.3);
80
+ }
81
+
82
+ .btn-login {
83
+ font-size: 0.8rem;
84
+ font-weight: 600;
85
+ color: var(--text-color);
86
+ background: var(--accent);
87
+ padding: 4px 12px;
88
+ border-radius: 6px;
89
+ text-decoration: none;
90
+ transition: background 0.2s;
91
+ }
92
+
93
+ .btn-login:hover {
94
+ background: var(--accent-hover);
95
+ }
96
+
97
  .container {
98
  display: flex;
99
  width: 100%;
100
+ flex: 1;
101
  flex-wrap: wrap;
102
+ overflow: hidden;
103
  }
104
 
105
  .pane {
app/page.js CHANGED
@@ -61,6 +61,23 @@ export default function Home() {
61
  });
62
  }, []);
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  // Update currentDoc when selection changes
65
  useEffect(() => {
66
  if (selectedDocIndex !== null) {
@@ -337,77 +354,92 @@ export default function Home() {
337
  }
338
 
339
  return (
340
- <div className="container">
341
- <div className="pane left-pane">
342
- <div className="pane-header">
343
- <h2>PDF Viewer</h2>
344
- <DocumentSelector
345
- documents={documents}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  selectedDocIndex={selectedDocIndex}
347
- onDocChange={handleDocChange}
 
 
 
348
  />
349
  </div>
350
- <PdfViewer
351
- pdfUrl={currentDoc?.pdf_url}
352
- pageNumber={currentPageNumber}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  />
354
- </div>
355
 
356
- <div className="pane right-pane">
357
- <MarkdownAnnotator
358
- selectedDocIndex={selectedDocIndex}
359
- selectedPage={currentPageNumber}
360
- currentPageData={currentPageData}
361
- loadingPage={loadingPage}
362
- onAnnotate={handleAnnotate}
 
 
 
 
 
 
 
 
 
 
 
363
  />
364
- </div>
365
 
366
- {/* Floating chevron to open annotations panel */}
367
- <button
368
- className="panel-chevron"
369
- onClick={() => setPanelOpen(prev => !prev)}
370
- title="Toggle annotations"
371
- >
372
- {panelOpen ? '›' : '‹'}
373
- {!panelOpen && currentPageDatasets.length > 0 && (
374
- <span className="chevron-badge">{currentPageDatasets.length}</span>
375
  )}
376
- </button>
377
- <AnnotationPanel
378
- isOpen={panelOpen}
379
- onClose={() => setPanelOpen(false)}
380
- datasets={currentPageDatasets}
381
- annotatorName={annotatorName}
382
- onValidate={handleValidateDataset}
383
- onDelete={handleDeleteAnnotation}
384
- />
385
-
386
- {/* Shared page navigator at the bottom */}
387
- <div className="bottom-nav">
388
- <PageNavigator
389
- currentIndex={pageIdx}
390
- totalPages={annotatablePages.length}
391
- currentPageNumber={currentPageNumber}
392
- onPrevious={handlePrevPage}
393
- onNext={handleNextPage}
394
- />
395
  </div>
396
-
397
- <AnnotationModal
398
- isOpen={modalOpen}
399
- selectedText={selectedText}
400
- annotatorName={annotatorName}
401
- onAnnotatorChange={handleAnnotatorChange}
402
- onSubmit={handleAnnotationSubmit}
403
- onClose={() => setModalOpen(false)}
404
- />
405
-
406
- {toast && (
407
- <div className={`toast toast-${toast.type}`}>
408
- {toast.message}
409
- </div>
410
- )}
411
  </div>
412
  );
413
  }
 
61
  });
62
  }, []);
63
 
64
+ // Read HF OAuth cookie for annotator identity
65
+ useEffect(() => {
66
+ try {
67
+ const cookie = document.cookie
68
+ .split('; ')
69
+ .find(c => c.startsWith('hf_user='));
70
+ if (cookie) {
71
+ const user = JSON.parse(decodeURIComponent(cookie.split('=').slice(1).join('=')));
72
+ if (user.username) {
73
+ setAnnotatorName(user.username);
74
+ }
75
+ }
76
+ } catch (e) {
77
+ console.warn('Could not read hf_user cookie', e);
78
+ }
79
+ }, []);
80
+
81
  // Update currentDoc when selection changes
82
  useEffect(() => {
83
  if (selectedDocIndex !== null) {
 
354
  }
355
 
356
  return (
357
+ <div className="app-wrapper">
358
+ {/* Top bar with user identity */}
359
+ <div className="top-bar">
360
+ <span className="top-bar-title">Annotation Tool</span>
361
+ <div className="top-bar-user">
362
+ {annotatorName ? (
363
+ <span className="user-badge">👤 {annotatorName}</span>
364
+ ) : (
365
+ <a href="/api/auth/login" className="btn btn-login" target="_blank" rel="noopener">
366
+ 🔑 Sign in with HF
367
+ </a>
368
+ )}
369
+ </div>
370
+ </div>
371
+ <div className="container">
372
+ <div className="pane left-pane">
373
+ <div className="pane-header">
374
+ <h2>PDF Viewer</h2>
375
+ <DocumentSelector
376
+ documents={documents}
377
+ selectedDocIndex={selectedDocIndex}
378
+ onDocChange={handleDocChange}
379
+ />
380
+ </div>
381
+ <PdfViewer
382
+ pdfUrl={currentDoc?.pdf_url}
383
+ pageNumber={currentPageNumber}
384
+ />
385
+ </div>
386
+
387
+ <div className="pane right-pane">
388
+ <MarkdownAnnotator
389
  selectedDocIndex={selectedDocIndex}
390
+ selectedPage={currentPageNumber}
391
+ currentPageData={currentPageData}
392
+ loadingPage={loadingPage}
393
+ onAnnotate={handleAnnotate}
394
  />
395
  </div>
396
+
397
+ {/* Floating chevron to open annotations panel */}
398
+ <button
399
+ className="panel-chevron"
400
+ onClick={() => setPanelOpen(prev => !prev)}
401
+ title="Toggle annotations"
402
+ >
403
+ {panelOpen ? '›' : '‹'}
404
+ {!panelOpen && currentPageDatasets.length > 0 && (
405
+ <span className="chevron-badge">{currentPageDatasets.length}</span>
406
+ )}
407
+ </button>
408
+ <AnnotationPanel
409
+ isOpen={panelOpen}
410
+ onClose={() => setPanelOpen(false)}
411
+ datasets={currentPageDatasets}
412
+ annotatorName={annotatorName}
413
+ onValidate={handleValidateDataset}
414
+ onDelete={handleDeleteAnnotation}
415
  />
 
416
 
417
+ {/* Shared page navigator at the bottom */}
418
+ <div className="bottom-nav">
419
+ <PageNavigator
420
+ currentIndex={pageIdx}
421
+ totalPages={annotatablePages.length}
422
+ currentPageNumber={currentPageNumber}
423
+ onPrevious={handlePrevPage}
424
+ onNext={handleNextPage}
425
+ />
426
+ </div>
427
+
428
+ <AnnotationModal
429
+ isOpen={modalOpen}
430
+ selectedText={selectedText}
431
+ annotatorName={annotatorName}
432
+ onAnnotatorChange={handleAnnotatorChange}
433
+ onSubmit={handleAnnotationSubmit}
434
+ onClose={() => setModalOpen(false)}
435
  />
 
436
 
437
+ {toast && (
438
+ <div className={`toast toast-${toast.type}`}>
439
+ {toast.message}
440
+ </div>
 
 
 
 
 
441
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  </div>
444
  );
445
  }
utils/config.js CHANGED
@@ -1,4 +1,4 @@
1
  // Centralized configuration for the annotation app
2
  export const HF_DATASET_ID = process.env.HF_DATASET_REPO || 'ai4data/annotation_data';
3
  export const HF_DATASET_BASE_URL = `https://huggingface.co/datasets/${HF_DATASET_ID}`;
4
- export const MAX_DOCS_TO_SCAN = parseInt(process.env.MAX_DOCS_TO_SCAN || '5', 10);
 
1
  // Centralized configuration for the annotation app
2
  export const HF_DATASET_ID = process.env.HF_DATASET_REPO || 'ai4data/annotation_data';
3
  export const HF_DATASET_BASE_URL = `https://huggingface.co/datasets/${HF_DATASET_ID}`;
4
+ export const MAX_DOCS_TO_SCAN = parseInt(process.env.MAX_DOCS_TO_SCAN || '50', 10);