Paramjit Singh commited on
Commit
c1445c9
·
unverified ·
2 Parent(s): abd9d7d12c3446

Merge pull request #108 from SatyamPrakash09/feat/chat-export

Browse files
backend/app/routes/chat.py CHANGED
@@ -166,6 +166,79 @@ def get_chat_history(
166
  return ChatHistoryResponse(messages=formatted, document_id=document_id)
167
 
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  @router.delete("/history/{document_id}")
170
  def clear_chat_history(
171
  document_id: str,
@@ -200,3 +273,89 @@ def _save_message(
200
  )
201
  db.add(msg)
202
  db.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  return ChatHistoryResponse(messages=formatted, document_id=document_id)
167
 
168
 
169
+ @router.get("/export/{document_id}")
170
+ def export_chat_history(
171
+ document_id: str,
172
+ format: str = "md",
173
+ token: Optional[str] = None,
174
+ db: Session = Depends(get_db),
175
+ ):
176
+ """Export chat history for a document as a downloadable .md or .txt file.
177
+
178
+ Accepts auth via either:
179
+ - Authorization: Bearer <token> header (standard)
180
+ - ?token=<jwt> query parameter (for browser downloads)
181
+ """
182
+ from fastapi import Request
183
+ from app.auth import decode_token as _decode
184
+
185
+ # Resolve user from query-param token (browser download links can't set headers)
186
+ resolved_user = None
187
+ if token:
188
+ user_id = _decode(token)
189
+ if user_id:
190
+ resolved_user = db.query(User).filter(User.id == user_id).first()
191
+
192
+ if resolved_user is None:
193
+ raise HTTPException(status_code=401, detail="Authentication required")
194
+
195
+ if format not in ("md", "txt"):
196
+ raise HTTPException(status_code=400, detail="Format must be 'md' or 'txt'")
197
+
198
+ # Verify document exists and belongs to user
199
+ doc = db.query(Document).filter(
200
+ Document.id == document_id,
201
+ Document.user_id == resolved_user.id,
202
+ ).first()
203
+
204
+ if not doc:
205
+ raise HTTPException(status_code=404, detail="Document not found")
206
+
207
+ messages = (
208
+ db.query(ChatMessage)
209
+ .filter(
210
+ ChatMessage.user_id == resolved_user.id,
211
+ ChatMessage.document_id == document_id,
212
+ )
213
+ .order_by(ChatMessage.created_at.asc())
214
+ .all()
215
+ )
216
+
217
+ if not messages:
218
+ raise HTTPException(status_code=404, detail="No chat history found for this document")
219
+
220
+ if format == "md":
221
+ content = _format_markdown(doc, messages)
222
+ media_type = "text/markdown"
223
+ extension = "md"
224
+ else:
225
+ content = _format_plaintext(doc, messages)
226
+ media_type = "text/plain"
227
+ extension = "txt"
228
+
229
+ safe_name = doc.original_name.rsplit(".", 1)[0]
230
+ filename = f"{safe_name}_chat_history.{extension}"
231
+
232
+ from fastapi.responses import Response
233
+ return Response(
234
+ content=content,
235
+ media_type=media_type,
236
+ headers={
237
+ "Content-Disposition": f'attachment; filename="{filename}"',
238
+ },
239
+ )
240
+
241
+
242
  @router.delete("/history/{document_id}")
243
  def clear_chat_history(
244
  document_id: str,
 
273
  )
274
  db.add(msg)
275
  db.commit()
276
+
277
+
278
+ def _format_markdown(doc, messages) -> str:
279
+ """Format chat history as a Markdown document."""
280
+ lines = [
281
+ f"# Chat History — {doc.original_name}",
282
+ "",
283
+ f"**Document:** {doc.original_name} ",
284
+ f"**Exported at:** {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ",
285
+ f"**Total messages:** {len(messages)}",
286
+ "",
287
+ "---",
288
+ "",
289
+ ]
290
+
291
+ for msg in messages:
292
+ timestamp = msg.created_at.strftime("%Y-%m-%d %H:%M:%S") if msg.created_at else ""
293
+ role_label = "**You**" if msg.role == "user" else "**Assistant**"
294
+
295
+ lines.append(f"### {role_label}")
296
+ lines.append(f"*{timestamp}*")
297
+ lines.append("")
298
+ lines.append(msg.content)
299
+ lines.append("")
300
+
301
+ # Include source citations for assistant messages
302
+ if msg.role == "assistant" and msg.sources_json:
303
+ try:
304
+ sources = json.loads(msg.sources_json)
305
+ if sources:
306
+ lines.append("**Sources:**")
307
+ lines.append("")
308
+ for i, src in enumerate(sources, 1):
309
+ lines.append(f"> **[{i}]** {src.get('filename', 'Unknown')}, "
310
+ f"Page {src.get('page', '?')} "
311
+ f"(Confidence: {src.get('confidence', 0)}%)")
312
+ text_preview = src.get("text", "")[:150]
313
+ if text_preview:
314
+ lines.append(f"> {text_preview}...")
315
+ lines.append(">")
316
+ lines.append("")
317
+ except Exception:
318
+ pass
319
+
320
+ lines.append("---")
321
+ lines.append("")
322
+
323
+ return "\n".join(lines)
324
+
325
+
326
+ def _format_plaintext(doc, messages) -> str:
327
+ """Format chat history as plain text."""
328
+ lines = [
329
+ f"Chat History — {doc.original_name}",
330
+ f"Exported at: {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
331
+ f"Total messages: {len(messages)}",
332
+ "=" * 60,
333
+ "",
334
+ ]
335
+
336
+ for msg in messages:
337
+ timestamp = msg.created_at.strftime("%Y-%m-%d %H:%M:%S") if msg.created_at else ""
338
+ role_label = "You" if msg.role == "user" else "Assistant"
339
+
340
+ lines.append(f"[{role_label}] ({timestamp})")
341
+ lines.append(msg.content)
342
+
343
+ # Include source citations for assistant messages
344
+ if msg.role == "assistant" and msg.sources_json:
345
+ try:
346
+ sources = json.loads(msg.sources_json)
347
+ if sources:
348
+ lines.append("")
349
+ lines.append("Sources:")
350
+ for i, src in enumerate(sources, 1):
351
+ lines.append(f" [{i}] {src.get('filename', 'Unknown')}, "
352
+ f"Page {src.get('page', '?')} "
353
+ f"(Confidence: {src.get('confidence', 0)}%)")
354
+ except Exception:
355
+ pass
356
+
357
+ lines.append("-" * 60)
358
+ lines.append("")
359
+
360
+ return "\n".join(lines)
361
+
frontend/src/app/dashboard/page.tsx CHANGED
@@ -39,8 +39,10 @@ export default function DashboardPage() {
39
  // Load documents
40
  const loadDocuments = useCallback(async () => {
41
  try {
42
- const data = await api.get<{ documents: DocInfo[] }>("/api/v1/documents/");
43
- setDocuments(data.documents);
 
 
44
  setConnectionError("");
45
  } catch (err) {
46
  const message = err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
@@ -62,7 +64,7 @@ export default function DashboardPage() {
62
 
63
  // Poll for processing status
64
  useEffect(() => {
65
- const hasPending = documents.some(
66
  (d) => d.status === "pending" || d.status === "processing"
67
  );
68
  if (!hasPending) return;
 
39
  // Load documents
40
  const loadDocuments = useCallback(async () => {
41
  try {
42
+ const data = await api.get<{ documents?: DocInfo[]; items?: DocInfo[] }>(
43
+ "/api/v1/documents/"
44
+ );
45
+ setDocuments(data?.documents ?? data?.items ?? []);
46
  setConnectionError("");
47
  } catch (err) {
48
  const message = err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
 
64
 
65
  // Poll for processing status
66
  useEffect(() => {
67
+ const hasPending = (documents || []).some(
68
  (d) => d.status === "pending" || d.status === "processing"
69
  );
70
  if (!hasPending) return;
frontend/src/components/chat/ChatPanel.tsx CHANGED
@@ -2,12 +2,12 @@
2
 
3
  import { useState, useRef, useEffect } from "react";
4
  import type { DocInfo } from "@/app/dashboard/page";
5
- import { api } from "@/lib/api";
6
  import { Button } from "@/components/ui/button";
7
  import { Textarea } from "@/components/ui/textarea";
8
  import MessageBubble from "./MessageBubble";
9
  import SourceCard from "./SourceCard";
10
- import { Send, Loader2, Trash2, MessageSquare } from "lucide-react";
11
 
12
  export interface SourceChunk {
13
  text: string;
@@ -35,9 +35,11 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
35
  const [input, setInput] = useState("");
36
  const [streaming, setStreaming] = useState(false);
37
  const [isTyping, setIsTyping] = useState(false);
 
38
  const textareaRef = useRef<HTMLTextAreaElement>(null);
39
  const bottomRef = useRef<HTMLDivElement>(null);
40
  const prevDocId = useRef<string | null>(null);
 
41
 
42
  useEffect(() => {
43
  const textarea = textareaRef.current;
@@ -209,6 +211,32 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
209
  }
210
  };
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  const handleKeyDown = (e: React.KeyboardEvent) => {
213
  if (e.key === "Enter" && !e.shiftKey) {
214
  e.preventDefault();
@@ -291,14 +319,50 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
291
  )}
292
  </Button>
293
  {messages.length > 0 && (
294
- <Button
295
- variant="ghost"
296
- size="icon"
297
- onClick={handleClear}
298
- className="h-[44px] w-[44px] text-muted-foreground hover:text-destructive"
299
- >
300
- <Trash2 className="w-4 h-4" />
301
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  )}
303
  </div>
304
  </div>
 
2
 
3
  import { useState, useRef, useEffect } from "react";
4
  import type { DocInfo } from "@/app/dashboard/page";
5
+ import { api, API_BASE } from "@/lib/api";
6
  import { Button } from "@/components/ui/button";
7
  import { Textarea } from "@/components/ui/textarea";
8
  import MessageBubble from "./MessageBubble";
9
  import SourceCard from "./SourceCard";
10
+ import { Send, Loader2, Trash2, MessageSquare, Download } from "lucide-react";
11
 
12
  export interface SourceChunk {
13
  text: string;
 
35
  const [input, setInput] = useState("");
36
  const [streaming, setStreaming] = useState(false);
37
  const [isTyping, setIsTyping] = useState(false);
38
+ const [showExportMenu, setShowExportMenu] = useState(false);
39
  const textareaRef = useRef<HTMLTextAreaElement>(null);
40
  const bottomRef = useRef<HTMLDivElement>(null);
41
  const prevDocId = useRef<string | null>(null);
42
+ const exportMenuRef = useRef<HTMLDivElement>(null);
43
 
44
  useEffect(() => {
45
  const textarea = textareaRef.current;
 
211
  }
212
  };
213
 
214
+ const handleExport = (format: "md" | "txt") => {
215
+ if (!activeDoc) return;
216
+ setShowExportMenu(false);
217
+ const token = localStorage.getItem("token");
218
+ const url = `${API_BASE}/api/v1/chat/export/${activeDoc.id}?format=${format}&token=${token}`;
219
+ // Trigger download via a temporary anchor
220
+ const a = document.createElement("a");
221
+ a.href = url;
222
+ a.download = "";
223
+ document.body.appendChild(a);
224
+ a.click();
225
+ document.body.removeChild(a);
226
+ };
227
+
228
+ // Close export dropdown on outside click
229
+ useEffect(() => {
230
+ if (!showExportMenu) return;
231
+ const handleClickOutside = (e: MouseEvent) => {
232
+ if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as Node)) {
233
+ setShowExportMenu(false);
234
+ }
235
+ };
236
+ document.addEventListener("mousedown", handleClickOutside);
237
+ return () => document.removeEventListener("mousedown", handleClickOutside);
238
+ }, [showExportMenu]);
239
+
240
  const handleKeyDown = (e: React.KeyboardEvent) => {
241
  if (e.key === "Enter" && !e.shiftKey) {
242
  e.preventDefault();
 
319
  )}
320
  </Button>
321
  {messages.length > 0 && (
322
+ <>
323
+ {/* Export dropdown */}
324
+ <div className="relative" ref={exportMenuRef}>
325
+ <Button
326
+ id="export-chat-btn"
327
+ variant="ghost"
328
+ size="icon"
329
+ onClick={() => setShowExportMenu((v) => !v)}
330
+ className="h-[44px] w-[44px] text-muted-foreground hover:text-primary"
331
+ title="Export chat history"
332
+ >
333
+ <Download className="w-4 h-4" />
334
+ </Button>
335
+ {showExportMenu && (
336
+ <div className="absolute bottom-full mb-2 right-0 min-w-[160px] rounded-lg border border-border bg-popover p-1 shadow-lg animate-in fade-in slide-in-from-bottom-2 z-50">
337
+ <button
338
+ id="export-md-btn"
339
+ onClick={() => handleExport("md")}
340
+ className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
341
+ >
342
+ <span className="text-base">📝</span>
343
+ Markdown (.md)
344
+ </button>
345
+ <button
346
+ id="export-txt-btn"
347
+ onClick={() => handleExport("txt")}
348
+ className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
349
+ >
350
+ <span className="text-base">📄</span>
351
+ Plain Text (.txt)
352
+ </button>
353
+ </div>
354
+ )}
355
+ </div>
356
+ {/* Clear history */}
357
+ <Button
358
+ variant="ghost"
359
+ size="icon"
360
+ onClick={handleClear}
361
+ className="h-[44px] w-[44px] text-muted-foreground hover:text-destructive"
362
+ >
363
+ <Trash2 className="w-4 h-4" />
364
+ </Button>
365
+ </>
366
  )}
367
  </div>
368
  </div>
frontend/src/components/chat/SourceCard.tsx CHANGED
@@ -11,7 +11,7 @@ interface Props {
11
  onPageClick: (page: number) => void;
12
  }
13
 
14
- export default function SourceCard({ sources, onPageClick }: Props) {
15
  const [expanded, setExpanded] = useState(false);
16
 
17
  if (sources.length === 0) return null;
 
11
  onPageClick: (page: number) => void;
12
  }
13
 
14
+ export default function SourceCard({ sources = [], onPageClick }: Props) {
15
  const [expanded, setExpanded] = useState(false);
16
 
17
  if (sources.length === 0) return null;
frontend/src/components/document/DocumentSidebar.tsx CHANGED
@@ -19,7 +19,7 @@ interface Props {
19
  onDocumentsChange: () => void;
20
  }
21
 
22
- export default function DocumentSidebar({ documents, activeDoc, onSelectDoc, onDocumentsChange }: Props) {
23
  const [uploading, setUploading] = useState(false);
24
  const [uploadProgress, setUploadProgress] = useState(0);
25
  const [uploadError, setUploadError] = useState("");
 
19
  onDocumentsChange: () => void;
20
  }
21
 
22
+ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc, onDocumentsChange }: Props) {
23
  const [uploading, setUploading] = useState(false);
24
  const [uploadProgress, setUploadProgress] = useState(0);
25
  const [uploadError, setUploadError] = useState("");
frontend/src/lib/api.ts CHANGED
@@ -1,6 +1,7 @@
1
  /**
2
  * API client for the FastAPI backend.
3
- * Handles authentication headers and base URL configuration.
 
4
  */
5
 
6
  const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
@@ -9,10 +10,14 @@ const CONNECTION_ERROR_BANNER_MESSAGE = `⚠️ ${CONNECTION_ERROR_MESSAGE}`;
9
 
10
  interface FetchOptions extends RequestInit {
11
  token?: string;
 
 
12
  }
13
 
14
  class ApiClient {
15
  private baseUrl: string;
 
 
16
 
17
  constructor(baseUrl: string) {
18
  this.baseUrl = baseUrl;
@@ -23,6 +28,11 @@ class ApiClient {
23
  return localStorage.getItem("token");
24
  }
25
 
 
 
 
 
 
26
  private getHeaders(token?: string): HeadersInit {
27
  const headers: HeadersInit = {
28
  "Content-Type": "application/json",
@@ -47,6 +57,67 @@ class ApiClient {
47
  }
48
  }
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  private getPayloadMessage(payload: unknown): string | null {
51
  if (typeof payload === "string" && payload.trim()) {
52
  return payload;
@@ -92,6 +163,14 @@ class ApiClient {
92
  ...options,
93
  });
94
 
 
 
 
 
 
 
 
 
95
  if (!res.ok) {
96
  throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
97
  }
@@ -107,6 +186,14 @@ class ApiClient {
107
  ...options,
108
  });
109
 
 
 
 
 
 
 
 
 
110
  if (!res.ok) {
111
  throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
112
  }
@@ -129,6 +216,14 @@ class ApiClient {
129
  ...options,
130
  });
131
 
 
 
 
 
 
 
 
 
132
  if (!res.ok) {
133
  throw new Error(await this.getErrorMessage(res, res.statusText || "Upload failed"));
134
  }
@@ -143,6 +238,14 @@ class ApiClient {
143
  ...options,
144
  });
145
 
 
 
 
 
 
 
 
 
146
  if (!res.ok) {
147
  throw new Error(await this.getErrorMessage(res, res.statusText || "Delete failed"));
148
  }
@@ -155,12 +258,24 @@ class ApiClient {
155
  * Yields parsed SSE data objects.
156
  */
157
  async *streamPost(path: string, body: unknown): AsyncGenerator<{ type: string; data?: unknown }> {
158
- const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
159
  method: "POST",
160
  headers: this.getHeaders(),
161
  body: JSON.stringify(body),
162
  });
163
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  if (!res.ok) {
165
  throw new Error(await this.getErrorMessage(res, res.statusText || "Stream request failed"));
166
  }
 
1
  /**
2
  * API client for the FastAPI backend.
3
+ * Handles authentication headers, base URL configuration,
4
+ * and automatic token refresh on 401 responses.
5
  */
6
 
7
  const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
 
10
 
11
  interface FetchOptions extends RequestInit {
12
  token?: string;
13
+ /** Skip auto-refresh for this request (used internally for the refresh call itself) */
14
+ _skipRefresh?: boolean;
15
  }
16
 
17
  class ApiClient {
18
  private baseUrl: string;
19
+ /** Guards against multiple concurrent refresh attempts */
20
+ private refreshPromise: Promise<string | null> | null = null;
21
 
22
  constructor(baseUrl: string) {
23
  this.baseUrl = baseUrl;
 
28
  return localStorage.getItem("token");
29
  }
30
 
31
+ private getRefreshToken(): string | null {
32
+ if (typeof window === "undefined") return null;
33
+ return localStorage.getItem("refresh_token");
34
+ }
35
+
36
  private getHeaders(token?: string): HeadersInit {
37
  const headers: HeadersInit = {
38
  "Content-Type": "application/json",
 
57
  }
58
  }
59
 
60
+ /**
61
+ * Attempt to refresh the access token using the stored refresh token.
62
+ * Uses a mutex so only one refresh happens at a time — concurrent
63
+ * 401s all wait on the same promise.
64
+ */
65
+ private async tryRefreshToken(): Promise<string | null> {
66
+ // If a refresh is already in-flight, wait for it
67
+ if (this.refreshPromise) {
68
+ return this.refreshPromise;
69
+ }
70
+
71
+ const refreshToken = this.getRefreshToken();
72
+ if (!refreshToken) return null;
73
+
74
+ this.refreshPromise = (async () => {
75
+ try {
76
+ const res = await this.fetchWithConnectionError(`${this.baseUrl}/api/v1/auth/refresh`, {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({ refresh_token: refreshToken }),
80
+ });
81
+
82
+ if (!res.ok) {
83
+ // Refresh token is also expired/invalid — force logout
84
+ this.clearTokens();
85
+ return null;
86
+ }
87
+
88
+ const data = await res.json();
89
+ // Store the new tokens
90
+ localStorage.setItem("token", data.access_token);
91
+ if (data.refresh_token) {
92
+ localStorage.setItem("refresh_token", data.refresh_token);
93
+ }
94
+
95
+ // Dispatch a custom event so AuthProvider can update its state
96
+ window.dispatchEvent(
97
+ new CustomEvent("auth:tokens-refreshed", {
98
+ detail: { accessToken: data.access_token, refreshToken: data.refresh_token, user: data.user },
99
+ })
100
+ );
101
+
102
+ return data.access_token as string;
103
+ } catch {
104
+ this.clearTokens();
105
+ return null;
106
+ } finally {
107
+ this.refreshPromise = null;
108
+ }
109
+ })();
110
+
111
+ return this.refreshPromise;
112
+ }
113
+
114
+ private clearTokens(): void {
115
+ localStorage.removeItem("token");
116
+ localStorage.removeItem("refresh_token");
117
+ // Notify AuthProvider to update state
118
+ window.dispatchEvent(new CustomEvent("auth:logged-out"));
119
+ }
120
+
121
  private getPayloadMessage(payload: unknown): string | null {
122
  if (typeof payload === "string" && payload.trim()) {
123
  return payload;
 
163
  ...options,
164
  });
165
 
166
+ // Auto-refresh on 401
167
+ if (res.status === 401 && !options?._skipRefresh) {
168
+ const newToken = await this.tryRefreshToken();
169
+ if (newToken) {
170
+ return this.get<T>(path, { ...options, token: newToken, _skipRefresh: true });
171
+ }
172
+ }
173
+
174
  if (!res.ok) {
175
  throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
176
  }
 
186
  ...options,
187
  });
188
 
189
+ // Auto-refresh on 401
190
+ if (res.status === 401 && !options?._skipRefresh) {
191
+ const newToken = await this.tryRefreshToken();
192
+ if (newToken) {
193
+ return this.post<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
194
+ }
195
+ }
196
+
197
  if (!res.ok) {
198
  throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
199
  }
 
216
  ...options,
217
  });
218
 
219
+ // Auto-refresh on 401
220
+ if (res.status === 401 && !options?._skipRefresh) {
221
+ const newToken = await this.tryRefreshToken();
222
+ if (newToken) {
223
+ return this.postForm<T>(path, formData, { ...options, token: newToken, _skipRefresh: true });
224
+ }
225
+ }
226
+
227
  if (!res.ok) {
228
  throw new Error(await this.getErrorMessage(res, res.statusText || "Upload failed"));
229
  }
 
238
  ...options,
239
  });
240
 
241
+ // Auto-refresh on 401
242
+ if (res.status === 401 && !options?._skipRefresh) {
243
+ const newToken = await this.tryRefreshToken();
244
+ if (newToken) {
245
+ return this.delete<T>(path, { ...options, token: newToken, _skipRefresh: true });
246
+ }
247
+ }
248
+
249
  if (!res.ok) {
250
  throw new Error(await this.getErrorMessage(res, res.statusText || "Delete failed"));
251
  }
 
258
  * Yields parsed SSE data objects.
259
  */
260
  async *streamPost(path: string, body: unknown): AsyncGenerator<{ type: string; data?: unknown }> {
261
+ let res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
262
  method: "POST",
263
  headers: this.getHeaders(),
264
  body: JSON.stringify(body),
265
  });
266
 
267
+ // Auto-refresh on 401
268
+ if (res.status === 401) {
269
+ const newToken = await this.tryRefreshToken();
270
+ if (newToken) {
271
+ res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
272
+ method: "POST",
273
+ headers: this.getHeaders(newToken),
274
+ body: JSON.stringify(body),
275
+ });
276
+ }
277
+ }
278
+
279
  if (!res.ok) {
280
  throw new Error(await this.getErrorMessage(res, res.statusText || "Stream request failed"));
281
  }
frontend/src/lib/auth.tsx CHANGED
@@ -44,34 +44,66 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
44
  .then(setUser)
45
  .catch(() => {
46
  localStorage.removeItem("token");
 
47
  setToken(null);
48
  })
49
  .finally(() => setLoading(false));
50
  // eslint-disable-next-line react-hooks/exhaustive-deps
51
  }, []); // intentionally runs once on mount only
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const login = useCallback(async (email: string, password: string) => {
54
- const data = await api.post<{ access_token: string; user: User }>(
55
  "/api/v1/auth/login",
56
  { email, password }
57
  );
58
  localStorage.setItem("token", data.access_token);
 
59
  setToken(data.access_token);
60
  setUser(data.user);
61
  }, []);
62
 
63
  const register = useCallback(async (username: string, email: string, password: string) => {
64
- const data = await api.post<{ access_token: string; user: User }>(
65
  "/api/v1/auth/register",
66
  { username, email, password }
67
  );
68
  localStorage.setItem("token", data.access_token);
 
69
  setToken(data.access_token);
70
  setUser(data.user);
71
  }, []);
72
 
73
  const logout = useCallback(() => {
74
  localStorage.removeItem("token");
 
75
  setToken(null);
76
  setUser(null);
77
  }, []);
 
44
  .then(setUser)
45
  .catch(() => {
46
  localStorage.removeItem("token");
47
+ localStorage.removeItem("refresh_token");
48
  setToken(null);
49
  })
50
  .finally(() => setLoading(false));
51
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
  }, []); // intentionally runs once on mount only
53
 
54
+ // ── Listen for token refresh events from ApiClient ──
55
+ // When the API client auto-refreshes tokens, it dispatches custom events
56
+ // so this context stays in sync without prop drilling.
57
+ useEffect(() => {
58
+ const handleTokensRefreshed = (e: Event) => {
59
+ const detail = (e as CustomEvent).detail;
60
+ if (detail?.accessToken) {
61
+ setToken(detail.accessToken);
62
+ }
63
+ if (detail?.user) {
64
+ setUser(detail.user);
65
+ }
66
+ };
67
+
68
+ const handleLoggedOut = () => {
69
+ setToken(null);
70
+ setUser(null);
71
+ };
72
+
73
+ window.addEventListener("auth:tokens-refreshed", handleTokensRefreshed);
74
+ window.addEventListener("auth:logged-out", handleLoggedOut);
75
+
76
+ return () => {
77
+ window.removeEventListener("auth:tokens-refreshed", handleTokensRefreshed);
78
+ window.removeEventListener("auth:logged-out", handleLoggedOut);
79
+ };
80
+ }, []);
81
+
82
  const login = useCallback(async (email: string, password: string) => {
83
+ const data = await api.post<{ access_token: string; refresh_token: string; user: User }>(
84
  "/api/v1/auth/login",
85
  { email, password }
86
  );
87
  localStorage.setItem("token", data.access_token);
88
+ localStorage.setItem("refresh_token", data.refresh_token);
89
  setToken(data.access_token);
90
  setUser(data.user);
91
  }, []);
92
 
93
  const register = useCallback(async (username: string, email: string, password: string) => {
94
+ const data = await api.post<{ access_token: string; refresh_token: string; user: User }>(
95
  "/api/v1/auth/register",
96
  { username, email, password }
97
  );
98
  localStorage.setItem("token", data.access_token);
99
+ localStorage.setItem("refresh_token", data.refresh_token);
100
  setToken(data.access_token);
101
  setUser(data.user);
102
  }, []);
103
 
104
  const logout = useCallback(() => {
105
  localStorage.removeItem("token");
106
+ localStorage.removeItem("refresh_token");
107
  setToken(null);
108
  setUser(null);
109
  }, []);