Spaces:
Running
Running
Merge pull request #108 from SatyamPrakash09/feat/chat-export
Browse files- backend/app/routes/chat.py +159 -0
- frontend/src/app/dashboard/page.tsx +5 -3
- frontend/src/components/chat/ChatPanel.tsx +74 -10
- frontend/src/components/chat/SourceCard.tsx +1 -1
- frontend/src/components/document/DocumentSidebar.tsx +1 -1
- frontend/src/lib/api.ts +117 -2
- frontend/src/lib/auth.tsx +34 -2
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[] }>(
|
| 43 |
-
|
|
|
|
|
|
|
| 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 |
-
<
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 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 |
}, []);
|