apteva 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/App.m4hg4bxq.js +218 -0
  2. package/dist/index.html +4 -2
  3. package/dist/styles.css +1 -1
  4. package/package.json +1 -1
  5. package/src/auth/index.ts +386 -0
  6. package/src/auth/middleware.ts +183 -0
  7. package/src/binary.ts +19 -1
  8. package/src/db.ts +688 -45
  9. package/src/integrations/composio.ts +437 -0
  10. package/src/integrations/index.ts +80 -0
  11. package/src/openapi.ts +1724 -0
  12. package/src/routes/api.ts +1476 -118
  13. package/src/routes/auth.ts +242 -0
  14. package/src/server.ts +121 -11
  15. package/src/web/App.tsx +64 -19
  16. package/src/web/components/agents/AgentCard.tsx +24 -22
  17. package/src/web/components/agents/AgentPanel.tsx +810 -45
  18. package/src/web/components/agents/AgentsView.tsx +81 -9
  19. package/src/web/components/agents/CreateAgentModal.tsx +28 -1
  20. package/src/web/components/api/ApiDocsPage.tsx +583 -0
  21. package/src/web/components/auth/CreateAccountStep.tsx +176 -0
  22. package/src/web/components/auth/LoginPage.tsx +91 -0
  23. package/src/web/components/auth/index.ts +2 -0
  24. package/src/web/components/common/Icons.tsx +56 -0
  25. package/src/web/components/common/Modal.tsx +184 -1
  26. package/src/web/components/dashboard/Dashboard.tsx +70 -22
  27. package/src/web/components/index.ts +3 -0
  28. package/src/web/components/layout/Header.tsx +135 -18
  29. package/src/web/components/layout/Sidebar.tsx +87 -43
  30. package/src/web/components/mcp/IntegrationsPanel.tsx +743 -0
  31. package/src/web/components/mcp/McpPage.tsx +451 -63
  32. package/src/web/components/onboarding/OnboardingWizard.tsx +64 -8
  33. package/src/web/components/settings/SettingsPage.tsx +340 -26
  34. package/src/web/components/tasks/TasksPage.tsx +22 -20
  35. package/src/web/components/telemetry/TelemetryPage.tsx +163 -61
  36. package/src/web/context/AuthContext.tsx +230 -0
  37. package/src/web/context/ProjectContext.tsx +182 -0
  38. package/src/web/context/index.ts +5 -0
  39. package/src/web/hooks/useAgents.ts +18 -6
  40. package/src/web/hooks/useOnboarding.ts +20 -4
  41. package/src/web/hooks/useProviders.ts +15 -5
  42. package/src/web/icon.png +0 -0
  43. package/src/web/index.html +1 -1
  44. package/src/web/styles.css +12 -0
  45. package/src/web/types.ts +10 -1
  46. package/dist/App.3kb50qa3.js +0 -213
@@ -1,5 +1,6 @@
1
- import React, { useState, useEffect } from "react";
1
+ import React, { useState, useEffect, useCallback } from "react";
2
2
  import { TasksIcon } from "../common/Icons";
3
+ import { useAuth } from "../../context";
3
4
  import type { Task } from "../../types";
4
5
 
5
6
  interface TasksPageProps {
@@ -7,20 +8,14 @@ interface TasksPageProps {
7
8
  }
8
9
 
9
10
  export function TasksPage({ onSelectAgent }: TasksPageProps) {
11
+ const { authFetch } = useAuth();
10
12
  const [tasks, setTasks] = useState<Task[]>([]);
11
13
  const [loading, setLoading] = useState(true);
12
14
  const [filter, setFilter] = useState<string>("all");
13
15
 
14
- useEffect(() => {
15
- fetchTasks();
16
- // Refresh every 10 seconds
17
- const interval = setInterval(fetchTasks, 10000);
18
- return () => clearInterval(interval);
19
- }, [filter]);
20
-
21
- const fetchTasks = async () => {
16
+ const fetchTasks = useCallback(async () => {
22
17
  try {
23
- const res = await fetch(`/api/tasks?status=${filter}`);
18
+ const res = await authFetch(`/api/tasks?status=${filter}`);
24
19
  const data = await res.json();
25
20
  setTasks(data.tasks || []);
26
21
  } catch (e) {
@@ -28,7 +23,14 @@ export function TasksPage({ onSelectAgent }: TasksPageProps) {
28
23
  } finally {
29
24
  setLoading(false);
30
25
  }
31
- };
26
+ }, [authFetch, filter]);
27
+
28
+ useEffect(() => {
29
+ fetchTasks();
30
+ // Refresh every 10 seconds
31
+ const interval = setInterval(fetchTasks, 10000);
32
+ return () => clearInterval(interval);
33
+ }, [fetchTasks]);
32
34
 
33
35
  const statusColors: Record<string, string> = {
34
36
  pending: "bg-yellow-500/20 text-yellow-400",
@@ -47,21 +49,21 @@ export function TasksPage({ onSelectAgent }: TasksPageProps) {
47
49
  ];
48
50
 
49
51
  return (
50
- <div className="flex-1 p-6 overflow-auto">
51
- <div className="max-w-4xl mx-auto">
52
- <div className="flex items-center justify-between mb-6">
53
- <div>
54
- <h1 className="text-2xl font-semibold mb-1">Tasks</h1>
55
- <p className="text-[#666]">
52
+ <div className="flex-1 p-4 md:p-6 overflow-auto">
53
+ <div className="max-w-4xl">
54
+ <div className="mb-6">
55
+ <div className="mb-4">
56
+ <h1 className="text-xl md:text-2xl font-semibold mb-1">Tasks</h1>
57
+ <p className="text-sm text-[#666]">
56
58
  View tasks from all running agents
57
59
  </p>
58
60
  </div>
59
- <div className="flex gap-2">
61
+ <div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
60
62
  {filterOptions.map(opt => (
61
63
  <button
62
64
  key={opt.value}
63
65
  onClick={() => setFilter(opt.value)}
64
- className={`px-3 py-1.5 rounded text-sm transition ${
66
+ className={`px-3 py-1.5 rounded text-sm transition whitespace-nowrap ${
65
67
  filter === opt.value
66
68
  ? "bg-[#f97316] text-black"
67
69
  : "bg-[#1a1a1a] hover:bg-[#222]"
@@ -113,7 +115,7 @@ export function TasksPage({ onSelectAgent }: TasksPageProps) {
113
115
  </p>
114
116
  )}
115
117
 
116
- <div className="flex items-center gap-4 text-xs text-[#555]">
118
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[#555]">
117
119
  <span>Type: {task.type}</span>
118
120
  <span>Priority: {task.priority}</span>
119
121
  {task.recurrence && <span>Recurrence: {task.recurrence}</span>}
@@ -1,6 +1,6 @@
1
- import React, { useState, useEffect, useMemo, useRef } from "react";
1
+ import React, { useState, useEffect, useMemo, useRef, useCallback } from "react";
2
2
  import { Select } from "../common/Select";
3
- import { useTelemetryContext, type TelemetryEvent } from "../../context";
3
+ import { useTelemetryContext, useProjects, useAuth, type TelemetryEvent } from "../../context";
4
4
 
5
5
  interface TelemetryStats {
6
6
  total_events: number;
@@ -45,26 +45,33 @@ function extractEventStats(event: TelemetryEvent): {
45
45
 
46
46
  export function TelemetryPage() {
47
47
  const { events: realtimeEvents } = useTelemetryContext();
48
+ const { currentProjectId, currentProject } = useProjects();
49
+ const { authFetch } = useAuth();
48
50
  const [fetchedStats, setFetchedStats] = useState<TelemetryStats | null>(null);
49
51
  const [historicalEvents, setHistoricalEvents] = useState<TelemetryEvent[]>([]);
50
52
  const [fetchedUsage, setFetchedUsage] = useState<UsageByAgent[]>([]);
51
53
  const [loading, setLoading] = useState(true);
52
54
  const [filter, setFilter] = useState({
53
- category: "",
54
55
  level: "",
55
56
  agent_id: "",
56
57
  });
57
- const [agents, setAgents] = useState<Array<{ id: string; name: string }>>([]);
58
+ // Categories to hide (DATABASE hidden by default)
59
+ const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set(["DATABASE"]));
60
+ const [agents, setAgents] = useState<Array<{ id: string; name: string; projectId: string | null }>>([]);
58
61
  const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
59
62
 
60
63
  // Track IDs that were in the fetched stats to avoid double-counting
61
64
  const countedEventIdsRef = useRef<Set<string>>(new Set());
62
65
 
66
+ // Track which events are "new" (for animation) - stores event IDs with their arrival time
67
+ const [newEventIds, setNewEventIds] = useState<Set<string>>(new Set());
68
+ const seenEventIdsRef = useRef<Set<string>>(new Set());
69
+
63
70
  // Fetch agents for dropdown
64
71
  useEffect(() => {
65
72
  const fetchAgents = async () => {
66
73
  try {
67
- const res = await fetch("/api/agents");
74
+ const res = await authFetch("/api/agents");
68
75
  const data = await res.json();
69
76
  setAgents(data.agents || []);
70
77
  } catch (e) {
@@ -72,25 +79,40 @@ export function TelemetryPage() {
72
79
  }
73
80
  };
74
81
  fetchAgents();
75
- }, []);
82
+ }, [authFetch]);
83
+
84
+ // Filter agents by project
85
+ const filteredAgents = useMemo(() => {
86
+ if (currentProjectId === null) return agents;
87
+ if (currentProjectId === "unassigned") return agents.filter(a => !a.projectId);
88
+ return agents.filter(a => a.projectId === currentProjectId);
89
+ }, [agents, currentProjectId]);
90
+
91
+ // Get agent IDs for the current project
92
+ const projectAgentIds = useMemo(() => new Set(filteredAgents.map(a => a.id)), [filteredAgents]);
76
93
 
77
94
  // Fetch stats and historical data (less frequently now since we have real-time)
78
95
  const fetchData = async () => {
79
96
  setLoading(true);
80
97
  try {
98
+ // Build project filter param
99
+ const projectParam = currentProjectId === "unassigned" ? "null" : currentProjectId || "";
100
+
81
101
  // Fetch stats
82
- const statsRes = await fetch("/api/telemetry/stats");
102
+ const statsParams = new URLSearchParams();
103
+ if (projectParam) statsParams.set("project_id", projectParam);
104
+ const statsRes = await authFetch(`/api/telemetry/stats${statsParams.toString() ? `?${statsParams}` : ""}`);
83
105
  const statsData = await statsRes.json();
84
106
  setFetchedStats(statsData.stats);
85
107
 
86
108
  // Fetch historical events with filters
87
109
  const params = new URLSearchParams();
88
- if (filter.category) params.set("category", filter.category);
89
110
  if (filter.level) params.set("level", filter.level);
90
111
  if (filter.agent_id) params.set("agent_id", filter.agent_id);
91
- params.set("limit", "50");
112
+ if (projectParam) params.set("project_id", projectParam);
113
+ params.set("limit", "100"); // Fetch more since we filter client-side
92
114
 
93
- const eventsRes = await fetch(`/api/telemetry/events?${params}`);
115
+ const eventsRes = await authFetch(`/api/telemetry/events?${params}`);
94
116
  const eventsData = await eventsRes.json();
95
117
  const events = eventsData.events || [];
96
118
  setHistoricalEvents(events);
@@ -99,7 +121,10 @@ export function TelemetryPage() {
99
121
  countedEventIdsRef.current = new Set(events.map((e: TelemetryEvent) => e.id));
100
122
 
101
123
  // Fetch usage by agent
102
- const usageRes = await fetch("/api/telemetry/usage?group_by=agent");
124
+ const usageParams = new URLSearchParams();
125
+ usageParams.set("group_by", "agent");
126
+ if (projectParam) usageParams.set("project_id", projectParam);
127
+ const usageRes = await authFetch(`/api/telemetry/usage?${usageParams}`);
103
128
  const usageData = await usageRes.json();
104
129
  setFetchedUsage(usageData.usage || []);
105
130
  } catch (e) {
@@ -113,7 +138,7 @@ export function TelemetryPage() {
113
138
  // Refresh stats every 60 seconds (events come in real-time)
114
139
  const interval = setInterval(fetchData, 60000);
115
140
  return () => clearInterval(interval);
116
- }, [filter]);
141
+ }, [filter, currentProjectId, authFetch]);
117
142
 
118
143
  // Compute real-time stats from new events (not already counted in fetched stats)
119
144
  const stats = useMemo(() => {
@@ -188,20 +213,33 @@ export function TelemetryPage() {
188
213
  const allEvents = React.useMemo(() => {
189
214
  // Apply filters to real-time events
190
215
  let filtered = realtimeEvents;
216
+
217
+ // Filter by project (for real-time events)
218
+ if (currentProjectId !== null) {
219
+ filtered = filtered.filter(e => projectAgentIds.has(e.agent_id));
220
+ }
221
+
191
222
  if (filter.agent_id) {
192
223
  filtered = filtered.filter(e => e.agent_id === filter.agent_id);
193
224
  }
194
- if (filter.category) {
195
- filtered = filtered.filter(e => e.category === filter.category);
225
+ // Filter out hidden categories
226
+ if (hiddenCategories.size > 0) {
227
+ filtered = filtered.filter(e => !hiddenCategories.has(e.category));
196
228
  }
197
229
  if (filter.level) {
198
230
  filtered = filtered.filter(e => e.level === filter.level);
199
231
  }
200
232
 
233
+ // Filter historical events too
234
+ let filteredHistorical = historicalEvents;
235
+ if (hiddenCategories.size > 0) {
236
+ filteredHistorical = filteredHistorical.filter(e => !hiddenCategories.has(e.category));
237
+ }
238
+
201
239
  // Merge with historical, dedupe by ID
202
240
  const seen = new Set(filtered.map(e => e.id));
203
241
  const merged = [...filtered];
204
- for (const evt of historicalEvents) {
242
+ for (const evt of filteredHistorical) {
205
243
  if (!seen.has(evt.id)) {
206
244
  merged.push(evt);
207
245
  seen.add(evt.id);
@@ -212,7 +250,35 @@ export function TelemetryPage() {
212
250
  merged.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
213
251
 
214
252
  return merged.slice(0, 100);
215
- }, [realtimeEvents, historicalEvents, filter]);
253
+ }, [realtimeEvents, historicalEvents, filter, hiddenCategories, currentProjectId, projectAgentIds]);
254
+
255
+ // Track new events for animation - mark events as "new" when they first appear
256
+ useEffect(() => {
257
+ const newIds: string[] = [];
258
+ for (const event of realtimeEvents) {
259
+ if (!seenEventIdsRef.current.has(event.id)) {
260
+ seenEventIdsRef.current.add(event.id);
261
+ newIds.push(event.id);
262
+ }
263
+ }
264
+
265
+ if (newIds.length > 0) {
266
+ setNewEventIds(prev => {
267
+ const updated = new Set(prev);
268
+ newIds.forEach(id => updated.add(id));
269
+ return updated;
270
+ });
271
+
272
+ // Remove "new" status after 5 seconds
273
+ setTimeout(() => {
274
+ setNewEventIds(prev => {
275
+ const updated = new Set(prev);
276
+ newIds.forEach(id => updated.delete(id));
277
+ return updated;
278
+ });
279
+ }, 5000);
280
+ }
281
+ }, [realtimeEvents]);
216
282
 
217
283
  const getAgentName = (agentId: string) => {
218
284
  const agent = agents.find(a => a.id === agentId);
@@ -241,23 +307,26 @@ export function TelemetryPage() {
241
307
  TASK: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
242
308
  MEMORY: "bg-cyan-500/20 text-cyan-400 border-cyan-500/30",
243
309
  MCP: "bg-orange-500/20 text-orange-400 border-orange-500/30",
310
+ DATABASE: "bg-pink-500/20 text-pink-400 border-pink-500/30",
311
+ };
312
+
313
+ const allCategories = ["LLM", "TOOL", "CHAT", "TASK", "MEMORY", "MCP", "SYSTEM", "DATABASE", "ERROR"];
314
+
315
+ const toggleCategory = (category: string) => {
316
+ setHiddenCategories(prev => {
317
+ const updated = new Set(prev);
318
+ if (updated.has(category)) {
319
+ updated.delete(category);
320
+ } else {
321
+ updated.add(category);
322
+ }
323
+ return updated;
324
+ });
244
325
  };
245
326
 
246
327
  const agentOptions = [
247
328
  { value: "", label: "All Agents" },
248
- ...agents.map(a => ({ value: a.id, label: a.name })),
249
- ];
250
-
251
- const categoryOptions = [
252
- { value: "", label: "All Categories" },
253
- { value: "LLM", label: "LLM" },
254
- { value: "TOOL", label: "Tool" },
255
- { value: "CHAT", label: "Chat" },
256
- { value: "TASK", label: "Task" },
257
- { value: "MEMORY", label: "Memory" },
258
- { value: "MCP", label: "MCP" },
259
- { value: "SYSTEM", label: "System" },
260
- { value: "ERROR", label: "Error" },
329
+ ...filteredAgents.map(a => ({ value: a.id, label: a.name })),
261
330
  ];
262
331
 
263
332
  const levelOptions = [
@@ -270,10 +339,24 @@ export function TelemetryPage() {
270
339
 
271
340
  return (
272
341
  <div className="flex-1 overflow-auto p-6">
273
- <div className="max-w-6xl">
342
+ <div>
274
343
  {/* Header */}
275
344
  <div className="mb-6">
276
- <h1 className="text-2xl font-semibold mb-1">Telemetry</h1>
345
+ <div className="flex items-center gap-3 mb-1">
346
+ {currentProject && (
347
+ <span
348
+ className="w-3 h-3 rounded-full"
349
+ style={{ backgroundColor: currentProject.color }}
350
+ />
351
+ )}
352
+ <h1 className="text-2xl font-semibold">
353
+ {currentProjectId === null
354
+ ? "Telemetry"
355
+ : currentProjectId === "unassigned"
356
+ ? "Telemetry - Unassigned"
357
+ : `Telemetry - ${currentProject?.name || ""}`}
358
+ </h1>
359
+ </div>
277
360
  <p className="text-[#666]">
278
361
  Monitor agent activity, token usage, and errors.
279
362
  </p>
@@ -331,8 +414,8 @@ export function TelemetryPage() {
331
414
  )}
332
415
 
333
416
  {/* Filters */}
334
- <div className="flex items-center gap-3 mb-4">
335
- <div className="w-56">
417
+ <div className="flex flex-wrap items-center gap-3 mb-4">
418
+ <div className="w-44">
336
419
  <Select
337
420
  value={filter.agent_id}
338
421
  options={agentOptions}
@@ -340,28 +423,42 @@ export function TelemetryPage() {
340
423
  placeholder="All Agents"
341
424
  />
342
425
  </div>
343
- <div className="w-48">
344
- <Select
345
- value={filter.category}
346
- options={categoryOptions}
347
- onChange={(value) => setFilter({ ...filter, category: value })}
348
- placeholder="All Categories"
349
- />
426
+ {/* Category toggles */}
427
+ <div className="flex flex-wrap items-center gap-1.5 flex-1">
428
+ {allCategories.map((cat) => {
429
+ const isHidden = hiddenCategories.has(cat);
430
+ const colorClass = categoryColors[cat] || "bg-[#222] text-[#888] border-[#333]";
431
+ return (
432
+ <button
433
+ key={cat}
434
+ onClick={() => toggleCategory(cat)}
435
+ className={`px-2 py-0.5 rounded text-xs border transition-all ${
436
+ isHidden
437
+ ? "bg-[#1a1a1a] text-[#555] border-[#333] opacity-50"
438
+ : colorClass
439
+ }`}
440
+ >
441
+ {cat}
442
+ </button>
443
+ );
444
+ })}
350
445
  </div>
351
- <div className="w-40">
352
- <Select
353
- value={filter.level}
354
- options={levelOptions}
355
- onChange={(value) => setFilter({ ...filter, level: value })}
356
- placeholder="All Levels"
357
- />
446
+ <div className="flex items-center gap-2">
447
+ <div className="w-36">
448
+ <Select
449
+ value={filter.level}
450
+ options={levelOptions}
451
+ onChange={(value) => setFilter({ ...filter, level: value })}
452
+ placeholder="All Levels"
453
+ />
454
+ </div>
455
+ <button
456
+ onClick={fetchData}
457
+ className="px-3 py-2 bg-[#1a1a1a] hover:bg-[#222] border border-[#333] rounded text-sm transition"
458
+ >
459
+ Refresh
460
+ </button>
358
461
  </div>
359
- <button
360
- onClick={fetchData}
361
- className="px-3 py-2 bg-[#1a1a1a] hover:bg-[#222] border border-[#333] rounded text-sm transition"
362
- >
363
- Refresh
364
- </button>
365
462
  </div>
366
463
 
367
464
  {/* Events List */}
@@ -384,18 +481,21 @@ export function TelemetryPage() {
384
481
  ) : (
385
482
  <div className="divide-y divide-[#1a1a1a]">
386
483
  {allEvents.map((event) => {
387
- // Only mark as new if event arrived in last 10 seconds
388
- const eventTime = new Date(event.timestamp).getTime();
389
- const isNew = Date.now() - eventTime < 10000;
484
+ const isNew = newEventIds.has(event.id);
390
485
 
391
486
  return (
392
487
  <div
393
488
  key={event.id}
394
- className="p-3 hover:bg-[#0a0a0a] transition cursor-pointer"
489
+ className={`p-3 hover:bg-[#0a0a0a] cursor-pointer transition-all duration-500 ${
490
+ isNew ? "bg-green-500/5" : ""
491
+ }`}
492
+ style={{
493
+ animation: isNew ? "slideIn 0.3s ease-out" : undefined,
494
+ }}
395
495
  onClick={() => setExpandedEvent(expandedEvent === event.id ? null : event.id)}
396
496
  >
397
497
  <div className="flex items-start gap-3">
398
- <span className={`px-2 py-0.5 rounded text-xs border ${categoryColors[event.category] || "bg-[#222] text-[#888] border-[#333]"}`}>
498
+ <span className={`px-2 py-0.5 rounded text-xs border transition-colors duration-300 ${categoryColors[event.category] || "bg-[#222] text-[#888] border-[#333]"}`}>
399
499
  {event.category}
400
500
  </span>
401
501
  <div className="flex-1 min-w-0">
@@ -407,9 +507,11 @@ export function TelemetryPage() {
407
507
  {event.duration_ms && (
408
508
  <span className="text-xs text-[#555]">{event.duration_ms}ms</span>
409
509
  )}
410
- {isNew && (
411
- <span className="w-1.5 h-1.5 rounded-full bg-green-400" />
412
- )}
510
+ <span
511
+ className={`w-1.5 h-1.5 rounded-full bg-green-400 transition-opacity duration-1000 ${
512
+ isNew ? "opacity-100" : "opacity-0"
513
+ }`}
514
+ />
413
515
  </div>
414
516
  <div className="text-xs text-[#555] mt-1">
415
517
  {getAgentName(event.agent_id)} · {new Date(event.timestamp).toLocaleString()}
@@ -0,0 +1,230 @@
1
+ import React, { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from "react";
2
+
3
+ interface User {
4
+ id: string;
5
+ username: string;
6
+ role: "admin" | "user";
7
+ }
8
+
9
+ interface AuthStatus {
10
+ hasUsers: boolean;
11
+ authenticated: boolean;
12
+ user?: User;
13
+ }
14
+
15
+ interface AuthContextValue {
16
+ user: User | null;
17
+ isAuthenticated: boolean;
18
+ isLoading: boolean;
19
+ hasUsers: boolean | null;
20
+ accessToken: string | null;
21
+ login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
22
+ logout: () => Promise<void>;
23
+ refreshToken: () => Promise<boolean>;
24
+ checkAuth: () => Promise<void>;
25
+ authFetch: (url: string, options?: RequestInit) => Promise<Response>;
26
+ }
27
+
28
+ const AuthContext = createContext<AuthContextValue | null>(null);
29
+
30
+ export function useAuth(): AuthContextValue {
31
+ const context = useContext(AuthContext);
32
+ if (!context) {
33
+ throw new Error("useAuth must be used within an AuthProvider");
34
+ }
35
+ return context;
36
+ }
37
+
38
+ interface AuthProviderProps {
39
+ children: ReactNode;
40
+ }
41
+
42
+ export function AuthProvider({ children }: AuthProviderProps) {
43
+ const [user, setUser] = useState<User | null>(null);
44
+ const [accessToken, setAccessToken] = useState<string | null>(null);
45
+ const [isLoading, setIsLoading] = useState(true);
46
+ const [hasUsers, setHasUsers] = useState<boolean | null>(null);
47
+
48
+ // Refs to track state without causing re-renders
49
+ const tokenRef = useRef<string | null>(null);
50
+ const refreshingRef = useRef(false);
51
+ const initializedRef = useRef(false);
52
+
53
+ // Helper to set token in both state and ref
54
+ const updateToken = useCallback((token: string | null) => {
55
+ tokenRef.current = token;
56
+ setAccessToken(token);
57
+ }, []);
58
+
59
+ // Internal refresh function - prevents concurrent refreshes
60
+ const refreshTokenInternal = useCallback(async (): Promise<boolean> => {
61
+ // Prevent concurrent refresh calls
62
+ if (refreshingRef.current) {
63
+ return false;
64
+ }
65
+ refreshingRef.current = true;
66
+
67
+ try {
68
+ const res = await fetch("/api/auth/refresh", {
69
+ method: "POST",
70
+ credentials: "include",
71
+ });
72
+
73
+ if (!res.ok) {
74
+ return false;
75
+ }
76
+
77
+ const data = await res.json();
78
+ updateToken(data.accessToken);
79
+
80
+ // Get user info with new token
81
+ const meRes = await fetch("/api/auth/me", {
82
+ headers: { Authorization: `Bearer ${data.accessToken}` },
83
+ });
84
+
85
+ if (meRes.ok) {
86
+ const meData = await meRes.json();
87
+ setUser(meData.user);
88
+ return true;
89
+ }
90
+
91
+ return false;
92
+ } catch (e) {
93
+ console.error("Token refresh failed:", e);
94
+ return false;
95
+ } finally {
96
+ refreshingRef.current = false;
97
+ }
98
+ }, [updateToken]);
99
+
100
+ // Check auth status
101
+ const checkAuth = useCallback(async () => {
102
+ try {
103
+ const token = tokenRef.current;
104
+ const res = await fetch("/api/auth/check", {
105
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
106
+ });
107
+ const data: AuthStatus = await res.json();
108
+
109
+ setHasUsers(data.hasUsers);
110
+
111
+ if (data.authenticated && data.user) {
112
+ setUser(data.user as User);
113
+ } else {
114
+ setUser(null);
115
+ // Try to refresh if we have users (meaning there might be a cookie)
116
+ if (data.hasUsers) {
117
+ const refreshed = await refreshTokenInternal();
118
+ if (!refreshed) {
119
+ updateToken(null);
120
+ }
121
+ }
122
+ }
123
+ } catch (e) {
124
+ console.error("Auth check failed:", e);
125
+ setUser(null);
126
+ updateToken(null);
127
+ } finally {
128
+ setIsLoading(false);
129
+ }
130
+ }, [refreshTokenInternal, updateToken]);
131
+
132
+ // Login
133
+ const login = useCallback(async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
134
+ try {
135
+ const res = await fetch("/api/auth/login", {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ credentials: "include",
139
+ body: JSON.stringify({ username, password }),
140
+ });
141
+
142
+ const data = await res.json();
143
+
144
+ if (!res.ok) {
145
+ return { success: false, error: data.error || "Login failed" };
146
+ }
147
+
148
+ updateToken(data.accessToken);
149
+ setUser(data.user);
150
+ setHasUsers(true);
151
+
152
+ return { success: true };
153
+ } catch (e) {
154
+ console.error("Login failed:", e);
155
+ return { success: false, error: "Login failed" };
156
+ }
157
+ }, [updateToken]);
158
+
159
+ // Logout
160
+ const logout = useCallback(async () => {
161
+ try {
162
+ const token = tokenRef.current;
163
+ await fetch("/api/auth/logout", {
164
+ method: "POST",
165
+ credentials: "include",
166
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
167
+ });
168
+ } catch (e) {
169
+ console.error("Logout failed:", e);
170
+ } finally {
171
+ setUser(null);
172
+ updateToken(null);
173
+ }
174
+ }, [updateToken]);
175
+
176
+ // Authenticated fetch wrapper - uses ref for latest token
177
+ const authFetch = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
178
+ const headers = new Headers(options.headers);
179
+ const token = tokenRef.current;
180
+ if (token) {
181
+ headers.set("Authorization", `Bearer ${token}`);
182
+ }
183
+ return fetch(url, { ...options, headers });
184
+ }, []);
185
+
186
+ // Public refresh function
187
+ const refreshToken = useCallback(async (): Promise<boolean> => {
188
+ return refreshTokenInternal();
189
+ }, [refreshTokenInternal]);
190
+
191
+ // Check auth on mount - only once
192
+ useEffect(() => {
193
+ if (initializedRef.current) return;
194
+ initializedRef.current = true;
195
+ checkAuth();
196
+ }, [checkAuth]);
197
+
198
+ // Set up token refresh interval
199
+ useEffect(() => {
200
+ if (!accessToken) return;
201
+
202
+ // Refresh token 1 minute before expiry (tokens last 15 min)
203
+ const refreshInterval = setInterval(() => {
204
+ refreshTokenInternal();
205
+ }, 14 * 60 * 1000); // 14 minutes
206
+
207
+ return () => clearInterval(refreshInterval);
208
+ }, [accessToken, refreshTokenInternal]);
209
+
210
+ const value: AuthContextValue = {
211
+ user,
212
+ isAuthenticated: !!user,
213
+ isLoading,
214
+ hasUsers,
215
+ accessToken,
216
+ login,
217
+ logout,
218
+ refreshToken,
219
+ checkAuth,
220
+ authFetch,
221
+ };
222
+
223
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
224
+ }
225
+
226
+ // Hook to get auth headers for API calls
227
+ export function useAuthHeaders(): Record<string, string> {
228
+ const { accessToken } = useAuth();
229
+ return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
230
+ }