bigwolfe commited on
Commit
ffcd038
·
1 Parent(s): e07f59c

Add demo token endpoint and read-only frontend handling

Browse files
backend/src/api/main.py CHANGED
@@ -21,7 +21,7 @@ from starlette.responses import Response
21
  from fastmcp.server.http import StreamableHTTPSessionManager
22
  from fastapi.responses import FileResponse
23
 
24
- from .routes import auth, index, notes, search, graph
25
  from ..mcp.server import mcp
26
  from ..services.seed import init_and_seed
27
 
@@ -95,6 +95,7 @@ app.include_router(notes.router, tags=["notes"])
95
  app.include_router(search.router, tags=["search"])
96
  app.include_router(index.router, tags=["index"])
97
  app.include_router(graph.router, tags=["graph"])
 
98
 
99
  # Hosted MCP HTTP endpoint (mounted Starlette app)
100
 
 
21
  from fastmcp.server.http import StreamableHTTPSessionManager
22
  from fastapi.responses import FileResponse
23
 
24
+ from .routes import auth, index, notes, search, graph, demo
25
  from ..mcp.server import mcp
26
  from ..services.seed import init_and_seed
27
 
 
95
  app.include_router(search.router, tags=["search"])
96
  app.include_router(index.router, tags=["index"])
97
  app.include_router(graph.router, tags=["graph"])
98
+ app.include_router(demo.router, tags=["demo"])
99
 
100
  # Hosted MCP HTTP endpoint (mounted Starlette app)
101
 
backend/src/api/routes/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
  """HTTP API route handlers."""
2
 
3
- from . import auth, index, notes, search
4
 
5
- __all__ = ["auth", "notes", "search", "index"]
 
1
  """HTTP API route handlers."""
2
 
3
+ from . import auth, index, notes, search, graph, demo
4
 
5
+ __all__ = ["auth", "notes", "search", "index", "graph", "demo"]
backend/src/api/routes/demo.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public endpoints for demo mode access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from ...services.auth import AuthService
10
+ from ...services.seed import ensure_welcome_note
11
+
12
+ DEMO_USER_ID = "demo-user"
13
+ DEMO_TOKEN_TTL_HOURS = 12
14
+
15
+ router = APIRouter()
16
+ auth_service = AuthService()
17
+
18
+
19
+ @router.get("/api/demo/token")
20
+ async def issue_demo_token():
21
+ """
22
+ Issue a short-lived JWT for the shared demo vault.
23
+
24
+ The caller can use this token to explore the application in read-only mode.
25
+ """
26
+ ensure_welcome_note(DEMO_USER_ID)
27
+ token, expires_at = auth_service.issue_token_response(
28
+ DEMO_USER_ID, expires_in=timedelta(hours=DEMO_TOKEN_TTL_HOURS)
29
+ )
30
+ return {
31
+ "token": token,
32
+ "token_type": "bearer",
33
+ "expires_at": expires_at.isoformat(),
34
+ "user_id": DEMO_USER_ID,
35
+ }
36
+
37
+
38
+ __all__ = ["router", "DEMO_USER_ID"]
39
+
backend/src/api/routes/index.py CHANGED
@@ -14,6 +14,19 @@ from ...services.indexer import IndexerService
14
  from ...services.vault import VaultService
15
  from ..middleware import AuthContext, get_auth_context
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  router = APIRouter()
18
 
19
 
@@ -79,6 +92,7 @@ async def rebuild_index(auth: AuthContext = Depends(get_auth_context)):
79
  """Rebuild the entire index from scratch."""
80
  start_time = time.time()
81
  user_id = auth.user_id
 
82
  vault_service = VaultService()
83
  indexer_service = IndexerService()
84
 
 
14
  from ...services.vault import VaultService
15
  from ..middleware import AuthContext, get_auth_context
16
 
17
+ DEMO_USER_ID = "demo-user"
18
+
19
+
20
+ def _ensure_index_mutation_allowed(user_id: str) -> None:
21
+ if user_id == DEMO_USER_ID:
22
+ raise HTTPException(
23
+ status_code=403,
24
+ detail={
25
+ "error": "demo_read_only",
26
+ "message": "Demo mode does not allow index rebuilds. Sign in to manage the index.",
27
+ },
28
+ )
29
+
30
  router = APIRouter()
31
 
32
 
 
92
  """Rebuild the entire index from scratch."""
93
  start_time = time.time()
94
  user_id = auth.user_id
95
+ _ensure_index_mutation_allowed(user_id)
96
  vault_service = VaultService()
97
  indexer_service = IndexerService()
98
 
backend/src/api/routes/notes.py CHANGED
@@ -16,6 +16,19 @@ from ..middleware import AuthContext, get_auth_context
16
 
17
  router = APIRouter()
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  class ConflictError(Exception):
21
  """Raised when optimistic concurrency check fails."""
@@ -60,6 +73,7 @@ async def list_notes(
60
  async def create_note(create: NoteCreate, auth: AuthContext = Depends(get_auth_context)):
61
  """Create a new note."""
62
  user_id = auth.user_id
 
63
  vault_service = VaultService()
64
  indexer_service = IndexerService()
65
  db_service = DatabaseService()
@@ -230,6 +244,7 @@ async def update_note(
230
  ):
231
  """Update a note with optimistic concurrency control."""
232
  user_id = auth.user_id
 
233
  vault_service = VaultService()
234
  indexer_service = IndexerService()
235
  db_service = DatabaseService()
@@ -337,9 +352,14 @@ class NoteMoveRequest(BaseModel):
337
 
338
 
339
  @router.patch("/api/notes/{path:path}", response_model=Note)
340
- async def move_note(path: str, move_request: NoteMoveRequest):
 
 
 
 
341
  """Move or rename a note to a new path."""
342
- user_id = get_user_id()
 
343
  vault_service = VaultService()
344
  indexer_service = IndexerService()
345
  db_service = DatabaseService()
 
16
 
17
  router = APIRouter()
18
 
19
+ DEMO_USER_ID = "demo-user"
20
+
21
+
22
+ def _ensure_write_allowed(user_id: str) -> None:
23
+ if user_id == DEMO_USER_ID:
24
+ raise HTTPException(
25
+ status_code=403,
26
+ detail={
27
+ "error": "demo_read_only",
28
+ "message": "Demo mode is read-only. Sign in with Hugging Face to make changes.",
29
+ },
30
+ )
31
+
32
 
33
  class ConflictError(Exception):
34
  """Raised when optimistic concurrency check fails."""
 
73
  async def create_note(create: NoteCreate, auth: AuthContext = Depends(get_auth_context)):
74
  """Create a new note."""
75
  user_id = auth.user_id
76
+ _ensure_write_allowed(user_id)
77
  vault_service = VaultService()
78
  indexer_service = IndexerService()
79
  db_service = DatabaseService()
 
244
  ):
245
  """Update a note with optimistic concurrency control."""
246
  user_id = auth.user_id
247
+ _ensure_write_allowed(user_id)
248
  vault_service = VaultService()
249
  indexer_service = IndexerService()
250
  db_service = DatabaseService()
 
352
 
353
 
354
  @router.patch("/api/notes/{path:path}", response_model=Note)
355
+ async def move_note(
356
+ path: str,
357
+ move_request: NoteMoveRequest,
358
+ auth: AuthContext = Depends(get_auth_context),
359
+ ):
360
  """Move or rename a note to a new path."""
361
+ user_id = auth.user_id
362
+ _ensure_write_allowed(user_id)
363
  vault_service = VaultService()
364
  indexer_service = IndexerService()
365
  db_service = DatabaseService()
frontend/src/App.tsx CHANGED
@@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from
3
  import { MainApp } from './pages/MainApp';
4
  import { Login } from './pages/Login';
5
  import { Settings } from './pages/Settings';
6
- import { isAuthenticated, getCurrentUser, setAuthTokenFromHash } from './services/auth';
7
  import { AuthLoadingSkeleton } from './components/AuthLoadingSkeleton';
8
  import { Toaster } from './components/ui/toaster';
9
  import './App.css';
@@ -28,13 +28,21 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
28
  }
29
 
30
  if (!isAuthenticated()) {
31
- setHasToken(false);
32
- setIsChecking(false);
33
- return;
 
 
 
34
  }
35
 
36
  setHasToken(true);
37
 
 
 
 
 
 
38
  const token = localStorage.getItem('auth_token');
39
  // Skip validation for local dev token
40
  if (token === 'local-dev-token') {
 
3
  import { MainApp } from './pages/MainApp';
4
  import { Login } from './pages/Login';
5
  import { Settings } from './pages/Settings';
6
+ import { isAuthenticated, getCurrentUser, setAuthTokenFromHash, ensureDemoToken, isDemoSession } from './services/auth';
7
  import { AuthLoadingSkeleton } from './components/AuthLoadingSkeleton';
8
  import { Toaster } from './components/ui/toaster';
9
  import './App.css';
 
28
  }
29
 
30
  if (!isAuthenticated()) {
31
+ const demoReady = await ensureDemoToken();
32
+ if (!demoReady) {
33
+ setHasToken(false);
34
+ setIsChecking(false);
35
+ return;
36
+ }
37
  }
38
 
39
  setHasToken(true);
40
 
41
+ if (isDemoSession()) {
42
+ setIsChecking(false);
43
+ return;
44
+ }
45
+
46
  const token = localStorage.getItem('auth_token');
47
  // Skip validation for local dev token
48
  if (token === 'local-dev-token') {
frontend/src/pages/MainApp.tsx CHANGED
@@ -41,6 +41,7 @@ import type { IndexHealth } from '@/types/search';
41
  import type { Note, NoteSummary } from '@/types/note';
42
  import { normalizeSlug } from '@/lib/wikilink';
43
  import { Network } from 'lucide-react';
 
44
 
45
  export function MainApp() {
46
  const navigate = useNavigate();
@@ -61,6 +62,21 @@ export function MainApp() {
61
  const [isNewFolderDialogOpen, setIsNewFolderDialogOpen] = useState(false);
62
  const [newFolderName, setNewFolderName] = useState('');
63
  const [isCreatingFolder, setIsCreatingFolder] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  // T083: Load directory tree on mount
66
  // T119: Load index health
@@ -169,6 +185,10 @@ export function MainApp() {
169
 
170
  // T093: Handle edit button click
171
  const handleEdit = () => {
 
 
 
 
172
  setIsEditMode(true);
173
  };
174
 
@@ -188,6 +208,10 @@ export function MainApp() {
188
 
189
  // Handle note dialog open change
190
  const handleDialogOpenChange = (open: boolean) => {
 
 
 
 
191
  setIsNewNoteDialogOpen(open);
192
  if (!open) {
193
  // Clear input when dialog closes
@@ -197,6 +221,10 @@ export function MainApp() {
197
 
198
  // Handle folder dialog open change
199
  const handleFolderDialogOpenChange = (open: boolean) => {
 
 
 
 
200
  setIsNewFolderDialogOpen(open);
201
  if (!open) {
202
  // Clear input when dialog closes
@@ -206,6 +234,10 @@ export function MainApp() {
206
 
207
  // Handle create new note
208
  const handleCreateNote = async () => {
 
 
 
 
209
  if (!newNoteName.trim() || isCreatingNote) return;
210
 
211
  setIsCreatingNote(true);
@@ -270,6 +302,10 @@ export function MainApp() {
270
 
271
  // Handle create new folder
272
  const handleCreateFolder = async () => {
 
 
 
 
273
  if (!newFolderName.trim() || isCreatingFolder) return;
274
 
275
  setIsCreatingFolder(true);
@@ -309,6 +345,10 @@ export function MainApp() {
309
 
310
  // Handle dragging file to folder
311
  const handleMoveNoteToFolder = async (oldPath: string, targetFolderPath: string) => {
 
 
 
 
312
  try {
313
  // Get the filename from the old path
314
  const fileName = oldPath.split('/').pop();
@@ -360,9 +400,19 @@ export function MainApp() {
360
 
361
  {/* Top bar */}
362
  <div className="border-b border-border p-4 animate-fade-in">
363
- <div className="flex items-center justify-between">
364
  <h1 className="text-xl font-semibold">📚 Document Viewer</h1>
365
  <div className="flex gap-2">
 
 
 
 
 
 
 
 
 
 
366
  <Button
367
  variant={isGraphView ? "secondary" : "ghost"}
368
  size="sm"
@@ -380,6 +430,16 @@ export function MainApp() {
380
 
381
  {/* Main content */}
382
  <div className="flex-1 overflow-hidden animate-fade-in" style={{ animationDelay: '0.1s' }}>
 
 
 
 
 
 
 
 
 
 
383
  <ResizablePanelGroup direction="horizontal">
384
  {/* Left sidebar */}
385
  <ResizablePanel defaultSize={25} minSize={15} maxSize={40}>
@@ -390,7 +450,7 @@ export function MainApp() {
390
  onOpenChange={handleDialogOpenChange}
391
  >
392
  <DialogTrigger asChild>
393
- <Button variant="outline" size="sm" className="w-full">
394
  <Plus className="h-4 w-4 mr-1" />
395
  New Note
396
  </Button>
@@ -440,7 +500,7 @@ export function MainApp() {
440
  onOpenChange={handleFolderDialogOpenChange}
441
  >
442
  <DialogTrigger asChild>
443
- <Button variant="outline" size="sm" className="w-full">
444
  <FolderPlus className="h-4 w-4 mr-1" />
445
  New Folder
446
  </Button>
@@ -536,7 +596,7 @@ export function MainApp() {
536
  <NoteViewer
537
  note={currentNote}
538
  backlinks={backlinks}
539
- onEdit={handleEdit}
540
  onWikilinkClick={handleWikilinkClick}
541
  />
542
  )
 
41
  import type { Note, NoteSummary } from '@/types/note';
42
  import { normalizeSlug } from '@/lib/wikilink';
43
  import { Network } from 'lucide-react';
44
+ import { AUTH_TOKEN_CHANGED_EVENT, isDemoSession, login } from '@/services/auth';
45
 
46
  export function MainApp() {
47
  const navigate = useNavigate();
 
62
  const [isNewFolderDialogOpen, setIsNewFolderDialogOpen] = useState(false);
63
  const [newFolderName, setNewFolderName] = useState('');
64
  const [isCreatingFolder, setIsCreatingFolder] = useState(false);
65
+ const [isDemoMode, setIsDemoMode] = useState<boolean>(isDemoSession());
66
+
67
+ useEffect(() => {
68
+ const handleAuthChange = () => {
69
+ const demo = isDemoSession();
70
+ setIsDemoMode(demo);
71
+ if (demo) {
72
+ setIsEditMode(false);
73
+ }
74
+ };
75
+ window.addEventListener(AUTH_TOKEN_CHANGED_EVENT, handleAuthChange);
76
+ return () => {
77
+ window.removeEventListener(AUTH_TOKEN_CHANGED_EVENT, handleAuthChange);
78
+ };
79
+ }, []);
80
 
81
  // T083: Load directory tree on mount
82
  // T119: Load index health
 
185
 
186
  // T093: Handle edit button click
187
  const handleEdit = () => {
188
+ if (isDemoMode) {
189
+ toast.error('Demo mode is read-only. Sign in with Hugging Face to edit notes.');
190
+ return;
191
+ }
192
  setIsEditMode(true);
193
  };
194
 
 
208
 
209
  // Handle note dialog open change
210
  const handleDialogOpenChange = (open: boolean) => {
211
+ if (open && isDemoMode) {
212
+ toast.error('Demo mode is read-only. Sign in with Hugging Face to create notes.');
213
+ return;
214
+ }
215
  setIsNewNoteDialogOpen(open);
216
  if (!open) {
217
  // Clear input when dialog closes
 
221
 
222
  // Handle folder dialog open change
223
  const handleFolderDialogOpenChange = (open: boolean) => {
224
+ if (open && isDemoMode) {
225
+ toast.error('Demo mode is read-only. Sign in with Hugging Face to create folders.');
226
+ return;
227
+ }
228
  setIsNewFolderDialogOpen(open);
229
  if (!open) {
230
  // Clear input when dialog closes
 
234
 
235
  // Handle create new note
236
  const handleCreateNote = async () => {
237
+ if (isDemoMode) {
238
+ toast.error('Demo mode is read-only. Sign in to create notes.');
239
+ return;
240
+ }
241
  if (!newNoteName.trim() || isCreatingNote) return;
242
 
243
  setIsCreatingNote(true);
 
302
 
303
  // Handle create new folder
304
  const handleCreateFolder = async () => {
305
+ if (isDemoMode) {
306
+ toast.error('Demo mode is read-only. Sign in to create folders.');
307
+ return;
308
+ }
309
  if (!newFolderName.trim() || isCreatingFolder) return;
310
 
311
  setIsCreatingFolder(true);
 
345
 
346
  // Handle dragging file to folder
347
  const handleMoveNoteToFolder = async (oldPath: string, targetFolderPath: string) => {
348
+ if (isDemoMode) {
349
+ toast.error('Demo mode is read-only. Sign in to move notes.');
350
+ return;
351
+ }
352
  try {
353
  // Get the filename from the old path
354
  const fileName = oldPath.split('/').pop();
 
400
 
401
  {/* Top bar */}
402
  <div className="border-b border-border p-4 animate-fade-in">
403
+ <div className="flex items-center justify-between gap-2">
404
  <h1 className="text-xl font-semibold">📚 Document Viewer</h1>
405
  <div className="flex gap-2">
406
+ {isDemoMode && (
407
+ <Button
408
+ variant="default"
409
+ size="sm"
410
+ onClick={() => login()}
411
+ title="Sign in with Hugging Face"
412
+ >
413
+ Sign in
414
+ </Button>
415
+ )}
416
  <Button
417
  variant={isGraphView ? "secondary" : "ghost"}
418
  size="sm"
 
430
 
431
  {/* Main content */}
432
  <div className="flex-1 overflow-hidden animate-fade-in" style={{ animationDelay: '0.1s' }}>
433
+ {isDemoMode && (
434
+ <div className="border-b border-border bg-muted/40 px-4 py-2 text-sm text-muted-foreground flex flex-wrap items-center justify-between gap-2">
435
+ <span>
436
+ You are browsing the shared demo vault in read-only mode. Sign in with your Hugging Face account to create and edit notes.
437
+ </span>
438
+ <Button variant="outline" size="sm" onClick={() => login()}>
439
+ Sign in
440
+ </Button>
441
+ </div>
442
+ )}
443
  <ResizablePanelGroup direction="horizontal">
444
  {/* Left sidebar */}
445
  <ResizablePanel defaultSize={25} minSize={15} maxSize={40}>
 
450
  onOpenChange={handleDialogOpenChange}
451
  >
452
  <DialogTrigger asChild>
453
+ <Button variant="outline" size="sm" className="w-full" disabled={isDemoMode}>
454
  <Plus className="h-4 w-4 mr-1" />
455
  New Note
456
  </Button>
 
500
  onOpenChange={handleFolderDialogOpenChange}
501
  >
502
  <DialogTrigger asChild>
503
+ <Button variant="outline" size="sm" className="w-full" disabled={isDemoMode}>
504
  <FolderPlus className="h-4 w-4 mr-1" />
505
  New Folder
506
  </Button>
 
596
  <NoteViewer
597
  note={currentNote}
598
  backlinks={backlinks}
599
+ onEdit={isDemoMode ? undefined : handleEdit}
600
  onWikilinkClick={handleWikilinkClick}
601
  />
602
  )
frontend/src/pages/Settings.tsx CHANGED
@@ -11,7 +11,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
11
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
12
  import { Separator } from '@/components/ui/separator';
13
  import { SettingsSectionSkeleton } from '@/components/SettingsSectionSkeleton';
14
- import { getCurrentUser, getToken, logout, getStoredToken } from '@/services/auth';
15
  import { getIndexHealth, rebuildIndex, type RebuildResponse } from '@/services/api';
16
  import type { User } from '@/types/user';
17
  import type { IndexHealth } from '@/types/search';
@@ -25,11 +25,18 @@ export function Settings() {
25
  const [isRebuilding, setIsRebuilding] = useState(false);
26
  const [rebuildResult, setRebuildResult] = useState<RebuildResponse | null>(null);
27
  const [error, setError] = useState<string | null>(null);
 
28
 
29
  useEffect(() => {
30
  loadData();
31
  }, []);
32
 
 
 
 
 
 
 
33
  const loadData = async () => {
34
  try {
35
  const token = getStoredToken();
@@ -60,6 +67,10 @@ export function Settings() {
60
  };
61
 
62
  const handleGenerateToken = async () => {
 
 
 
 
63
  try {
64
  setError(null);
65
  const tokenResponse = await getToken();
@@ -81,6 +92,10 @@ export function Settings() {
81
  };
82
 
83
  const handleRebuildIndex = async () => {
 
 
 
 
84
  setIsRebuilding(true);
85
  setError(null);
86
  setRebuildResult(null);
@@ -125,6 +140,13 @@ export function Settings() {
125
 
126
  {/* Content */}
127
  <div className="max-w-4xl mx-auto p-6 space-y-6">
 
 
 
 
 
 
 
128
  {error && (
129
  <Alert variant="destructive">
130
  <AlertDescription>{error}</AlertDescription>
@@ -203,7 +225,7 @@ export function Settings() {
203
  </div>
204
  </div>
205
 
206
- <Button onClick={handleGenerateToken}>
207
  <RefreshCw className="h-4 w-4 mr-2" />
208
  Generate New Token
209
  </Button>
@@ -285,7 +307,7 @@ export function Settings() {
285
 
286
  <Button
287
  onClick={handleRebuildIndex}
288
- disabled={isRebuilding}
289
  variant="outline"
290
  >
291
  <RefreshCw className={`h-4 w-4 mr-2 ${isRebuilding ? 'animate-spin' : ''}`} />
 
11
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
12
  import { Separator } from '@/components/ui/separator';
13
  import { SettingsSectionSkeleton } from '@/components/SettingsSectionSkeleton';
14
+ import { getCurrentUser, getToken, logout, getStoredToken, isDemoSession, AUTH_TOKEN_CHANGED_EVENT } from '@/services/auth';
15
  import { getIndexHealth, rebuildIndex, type RebuildResponse } from '@/services/api';
16
  import type { User } from '@/types/user';
17
  import type { IndexHealth } from '@/types/search';
 
25
  const [isRebuilding, setIsRebuilding] = useState(false);
26
  const [rebuildResult, setRebuildResult] = useState<RebuildResponse | null>(null);
27
  const [error, setError] = useState<string | null>(null);
28
+ const [isDemoMode, setIsDemoMode] = useState<boolean>(isDemoSession());
29
 
30
  useEffect(() => {
31
  loadData();
32
  }, []);
33
 
34
+ useEffect(() => {
35
+ const handler = () => setIsDemoMode(isDemoSession());
36
+ window.addEventListener(AUTH_TOKEN_CHANGED_EVENT, handler);
37
+ return () => window.removeEventListener(AUTH_TOKEN_CHANGED_EVENT, handler);
38
+ }, []);
39
+
40
  const loadData = async () => {
41
  try {
42
  const token = getStoredToken();
 
67
  };
68
 
69
  const handleGenerateToken = async () => {
70
+ if (isDemoMode) {
71
+ setError('Demo mode is read-only. Sign in to generate new tokens.');
72
+ return;
73
+ }
74
  try {
75
  setError(null);
76
  const tokenResponse = await getToken();
 
92
  };
93
 
94
  const handleRebuildIndex = async () => {
95
+ if (isDemoMode) {
96
+ setError('Demo mode is read-only. Sign in to rebuild the index.');
97
+ return;
98
+ }
99
  setIsRebuilding(true);
100
  setError(null);
101
  setRebuildResult(null);
 
140
 
141
  {/* Content */}
142
  <div className="max-w-4xl mx-auto p-6 space-y-6">
143
+ {isDemoMode && (
144
+ <Alert variant="destructive">
145
+ <AlertDescription>
146
+ You are viewing the shared demo vault. Sign in with Hugging Face from the main app to enable token generation and index management.
147
+ </AlertDescription>
148
+ </Alert>
149
+ )}
150
  {error && (
151
  <Alert variant="destructive">
152
  <AlertDescription>{error}</AlertDescription>
 
225
  </div>
226
  </div>
227
 
228
+ <Button onClick={handleGenerateToken} disabled={isDemoMode}>
229
  <RefreshCw className="h-4 w-4 mr-2" />
230
  Generate New Token
231
  </Button>
 
307
 
308
  <Button
309
  onClick={handleRebuildIndex}
310
+ disabled={isDemoMode || isRebuilding}
311
  variant="outline"
312
  >
313
  <RefreshCw className={`h-4 w-4 mr-2 ${isRebuilding ? 'animate-spin' : ''}`} />
frontend/src/services/api.ts CHANGED
@@ -139,6 +139,17 @@ export async function getBacklinks(path: string): Promise<BacklinkResult[]> {
139
  return apiFetch<BacklinkResult[]>(`/api/backlinks/${encodedPath}`);
140
  }
141
 
 
 
 
 
 
 
 
 
 
 
 
142
  /**
143
  * T070: Get all tags with counts
144
  */
 
139
  return apiFetch<BacklinkResult[]>(`/api/backlinks/${encodedPath}`);
140
  }
141
 
142
+ export interface DemoTokenResponse {
143
+ token: string;
144
+ token_type: string;
145
+ expires_at: string;
146
+ user_id: string;
147
+ }
148
+
149
+ export async function getDemoToken(): Promise<DemoTokenResponse> {
150
+ return apiFetch<DemoTokenResponse>('/api/demo/token');
151
+ }
152
+
153
  /**
154
  * T070: Get all tags with counts
155
  */
frontend/src/services/auth.ts CHANGED
@@ -3,9 +3,42 @@
3
  */
4
  import type { User } from '@/types/user';
5
  import type { TokenResponse } from '@/types/auth';
 
 
 
 
 
 
 
 
 
6
 
7
  const API_BASE = '';
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  /**
10
  * T105: Redirect to HF OAuth login
11
  */
@@ -17,7 +50,7 @@ export function login(): void {
17
  * Logout - clear token and redirect
18
  */
19
  export function logout(): void {
20
- localStorage.removeItem('auth_token');
21
  window.location.href = '/';
22
  }
23
 
@@ -25,7 +58,7 @@ export function logout(): void {
25
  * T106: Get current authenticated user
26
  */
27
  export async function getCurrentUser(): Promise<User> {
28
- const token = localStorage.getItem('auth_token');
29
 
30
  const response = await fetch(`${API_BASE}/api/me`, {
31
  headers: {
@@ -45,7 +78,7 @@ export async function getCurrentUser(): Promise<User> {
45
  * T107: Generate new API token for MCP access
46
  */
47
  export async function getToken(): Promise<TokenResponse> {
48
- const token = localStorage.getItem('auth_token');
49
 
50
  const response = await fetch(`${API_BASE}/api/tokens`, {
51
  method: 'POST',
@@ -62,7 +95,7 @@ export async function getToken(): Promise<TokenResponse> {
62
  const tokenResponse: TokenResponse = await response.json();
63
 
64
  // Store the new token
65
- localStorage.setItem('auth_token', tokenResponse.token);
66
 
67
  return tokenResponse;
68
  }
@@ -71,14 +104,25 @@ export async function getToken(): Promise<TokenResponse> {
71
  * Check if user is authenticated
72
  */
73
  export function isAuthenticated(): boolean {
74
- return !!localStorage.getItem('auth_token');
 
 
 
 
 
 
 
75
  }
76
 
77
  /**
78
  * Get stored token
79
  */
80
  export function getStoredToken(): string | null {
81
- return localStorage.getItem('auth_token');
 
 
 
 
82
  }
83
 
84
  /**
@@ -91,7 +135,7 @@ export function setAuthTokenFromHash(): boolean {
91
  if (hash.startsWith('#token=')) {
92
  const token = hash.substring(7); // Remove '#token='
93
  if (token) {
94
- localStorage.setItem('auth_token', token);
95
  // Clean up the URL
96
  window.history.replaceState(null, '', window.location.pathname);
97
  return true;
@@ -100,3 +144,38 @@ export function setAuthTokenFromHash(): boolean {
100
  return false;
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  */
4
  import type { User } from '@/types/user';
5
  import type { TokenResponse } from '@/types/auth';
6
+ import type { DemoTokenResponse } from '@/services/api';
7
+ import { getDemoToken } from '@/services/api';
8
+
9
+ const AUTH_TOKEN_KEY = 'auth_token';
10
+ const AUTH_TOKEN_SOURCE_KEY = 'auth_token_source';
11
+ const AUTH_TOKEN_EXPIRES_KEY = 'auth_token_expires_at';
12
+ export const AUTH_TOKEN_CHANGED_EVENT = 'auth-token-changed';
13
+
14
+ type TokenSource = 'user' | 'demo';
15
 
16
  const API_BASE = '';
17
 
18
+ function notifyTokenChange(): void {
19
+ if (typeof window !== 'undefined') {
20
+ window.dispatchEvent(new CustomEvent(AUTH_TOKEN_CHANGED_EVENT));
21
+ }
22
+ }
23
+
24
+ function storeAuthToken(token: string, source: TokenSource, expiresAt?: string): void {
25
+ localStorage.setItem(AUTH_TOKEN_KEY, token);
26
+ localStorage.setItem(AUTH_TOKEN_SOURCE_KEY, source);
27
+ if (expiresAt) {
28
+ localStorage.setItem(AUTH_TOKEN_EXPIRES_KEY, expiresAt);
29
+ } else {
30
+ localStorage.removeItem(AUTH_TOKEN_EXPIRES_KEY);
31
+ }
32
+ notifyTokenChange();
33
+ }
34
+
35
+ function clearStoredAuthToken(): void {
36
+ localStorage.removeItem(AUTH_TOKEN_KEY);
37
+ localStorage.removeItem(AUTH_TOKEN_SOURCE_KEY);
38
+ localStorage.removeItem(AUTH_TOKEN_EXPIRES_KEY);
39
+ notifyTokenChange();
40
+ }
41
+
42
  /**
43
  * T105: Redirect to HF OAuth login
44
  */
 
50
  * Logout - clear token and redirect
51
  */
52
  export function logout(): void {
53
+ clearStoredAuthToken();
54
  window.location.href = '/';
55
  }
56
 
 
58
  * T106: Get current authenticated user
59
  */
60
  export async function getCurrentUser(): Promise<User> {
61
+ const token = localStorage.getItem(AUTH_TOKEN_KEY);
62
 
63
  const response = await fetch(`${API_BASE}/api/me`, {
64
  headers: {
 
78
  * T107: Generate new API token for MCP access
79
  */
80
  export async function getToken(): Promise<TokenResponse> {
81
+ const token = localStorage.getItem(AUTH_TOKEN_KEY);
82
 
83
  const response = await fetch(`${API_BASE}/api/tokens`, {
84
  method: 'POST',
 
95
  const tokenResponse: TokenResponse = await response.json();
96
 
97
  // Store the new token
98
+ storeAuthToken(tokenResponse.token, 'user', tokenResponse.expires_at);
99
 
100
  return tokenResponse;
101
  }
 
104
  * Check if user is authenticated
105
  */
106
  export function isAuthenticated(): boolean {
107
+ const token = localStorage.getItem(AUTH_TOKEN_KEY);
108
+ if (!token) {
109
+ return false;
110
+ }
111
+ if (isDemoSession()) {
112
+ return !demoTokenExpired();
113
+ }
114
+ return true;
115
  }
116
 
117
  /**
118
  * Get stored token
119
  */
120
  export function getStoredToken(): string | null {
121
+ return localStorage.getItem(AUTH_TOKEN_KEY);
122
+ }
123
+
124
+ export function isDemoSession(): boolean {
125
+ return localStorage.getItem(AUTH_TOKEN_SOURCE_KEY) === 'demo';
126
  }
127
 
128
  /**
 
135
  if (hash.startsWith('#token=')) {
136
  const token = hash.substring(7); // Remove '#token='
137
  if (token) {
138
+ storeAuthToken(token, 'user');
139
  // Clean up the URL
140
  window.history.replaceState(null, '', window.location.pathname);
141
  return true;
 
144
  return false;
145
  }
146
 
147
+ function demoTokenExpired(): boolean {
148
+ const expiresAt = localStorage.getItem(AUTH_TOKEN_EXPIRES_KEY);
149
+ if (!expiresAt) {
150
+ return false;
151
+ }
152
+ const now = Date.now();
153
+ return new Date(expiresAt).getTime() <= now;
154
+ }
155
+
156
+ async function requestDemoToken(): Promise<DemoTokenResponse> {
157
+ return getDemoToken();
158
+ }
159
+
160
+ export async function ensureDemoToken(): Promise<boolean> {
161
+ // If we already have a user token, nothing to do
162
+ if (isAuthenticated() && !isDemoSession()) {
163
+ return true;
164
+ }
165
+
166
+ // If we have a demo token that hasn't expired, reuse it
167
+ if (isAuthenticated() && isDemoSession() && !demoTokenExpired()) {
168
+ return true;
169
+ }
170
+
171
+ try {
172
+ const demoResponse = await requestDemoToken();
173
+ storeAuthToken(demoResponse.token, 'demo', demoResponse.expires_at);
174
+ return true;
175
+ } catch (error) {
176
+ console.warn('Failed to obtain demo token', error);
177
+ clearStoredAuthToken();
178
+ return false;
179
+ }
180
+ }
181
+