apteva 0.4.56 → 0.7.0

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 (142) hide show
  1. package/README.md +216 -54
  2. package/cli.js +35 -0
  3. package/install.js +92 -0
  4. package/package.json +12 -79
  5. package/LICENSE +0 -63
  6. package/bin/apteva.js +0 -196
  7. package/dist/ActivityPage.kxzzb4yc.js +0 -3
  8. package/dist/ApiDocsPage.zq998hbm.js +0 -4
  9. package/dist/App.55rea8mn.js +0 -61
  10. package/dist/App.5ywb23z4.js +0 -53
  11. package/dist/App.6thds120.js +0 -4
  12. package/dist/App.9tctxzqm.js +0 -8
  13. package/dist/App.a8r8ttaz.js +0 -4
  14. package/dist/App.agsv5bje.js +0 -4
  15. package/dist/App.cepapqmx.js +0 -4
  16. package/dist/App.dp041gb3.js +0 -221
  17. package/dist/App.fds72zb5.js +0 -4
  18. package/dist/App.fg9qj2dq.js +0 -4
  19. package/dist/App.ndfejbm9.js +0 -4
  20. package/dist/App.nxmfmq1h.js +0 -13
  21. package/dist/App.qdfyt8ba.js +0 -4
  22. package/dist/App.x2d0ygt6.js +0 -4
  23. package/dist/App.yt9p4nr3.js +0 -20
  24. package/dist/App.zn4mw16t.js +0 -1
  25. package/dist/ConnectionsPage.8r96ryw7.js +0 -3
  26. package/dist/McpPage.3cwh0gnd.js +0 -3
  27. package/dist/SettingsPage.ykgdh5ev.js +0 -3
  28. package/dist/SkillsPage.4np1s65b.js +0 -3
  29. package/dist/TasksPage.4g08t7p6.js +0 -3
  30. package/dist/TelemetryPage.72w9pwcp.js +0 -3
  31. package/dist/TestsPage.z4fk3r7r.js +0 -3
  32. package/dist/ThreadsPage.63tcajeh.js +0 -3
  33. package/dist/apteva-kit.css +0 -1
  34. package/dist/icon.png +0 -0
  35. package/dist/index.html +0 -16
  36. package/dist/styles.css +0 -1
  37. package/scripts/postinstall.mjs +0 -102
  38. package/src/auth/index.ts +0 -394
  39. package/src/auth/middleware.ts +0 -213
  40. package/src/binary.ts +0 -536
  41. package/src/channels/index.ts +0 -40
  42. package/src/channels/telegram.ts +0 -311
  43. package/src/crypto.ts +0 -301
  44. package/src/db-tests.ts +0 -174
  45. package/src/db.ts +0 -3133
  46. package/src/integrations/agentdojo.ts +0 -559
  47. package/src/integrations/composio.ts +0 -437
  48. package/src/integrations/index.ts +0 -87
  49. package/src/integrations/skillsmp.ts +0 -318
  50. package/src/mcp-client.ts +0 -605
  51. package/src/mcp-handler.ts +0 -394
  52. package/src/mcp-platform.ts +0 -2370
  53. package/src/openapi.ts +0 -2410
  54. package/src/providers.ts +0 -597
  55. package/src/routes/api/agent-utils.ts +0 -890
  56. package/src/routes/api/agents.ts +0 -916
  57. package/src/routes/api/api-keys.ts +0 -95
  58. package/src/routes/api/channels.ts +0 -182
  59. package/src/routes/api/helpers.ts +0 -12
  60. package/src/routes/api/integrations.ts +0 -639
  61. package/src/routes/api/mcp.ts +0 -574
  62. package/src/routes/api/meta-agent.ts +0 -195
  63. package/src/routes/api/projects.ts +0 -112
  64. package/src/routes/api/providers.ts +0 -424
  65. package/src/routes/api/skills.ts +0 -537
  66. package/src/routes/api/system.ts +0 -333
  67. package/src/routes/api/telemetry.ts +0 -203
  68. package/src/routes/api/tests.ts +0 -148
  69. package/src/routes/api/triggers.ts +0 -518
  70. package/src/routes/api/users.ts +0 -148
  71. package/src/routes/api/webhooks.ts +0 -171
  72. package/src/routes/api.ts +0 -53
  73. package/src/routes/auth.ts +0 -251
  74. package/src/routes/share.ts +0 -86
  75. package/src/routes/static.ts +0 -131
  76. package/src/server.ts +0 -642
  77. package/src/test-runner.ts +0 -598
  78. package/src/triggers/agentdojo.ts +0 -253
  79. package/src/triggers/composio.ts +0 -264
  80. package/src/triggers/index.ts +0 -71
  81. package/src/tui/AgentList.tsx +0 -145
  82. package/src/tui/App.tsx +0 -102
  83. package/src/tui/Login.tsx +0 -104
  84. package/src/tui/api.ts +0 -72
  85. package/src/tui/index.tsx +0 -7
  86. package/src/web/App.tsx +0 -455
  87. package/src/web/components/activity/ActivityPage.tsx +0 -314
  88. package/src/web/components/activity/index.ts +0 -1
  89. package/src/web/components/agents/AgentCard.tsx +0 -189
  90. package/src/web/components/agents/AgentPanel.tsx +0 -2244
  91. package/src/web/components/agents/AgentsView.tsx +0 -180
  92. package/src/web/components/agents/CreateAgentModal.tsx +0 -475
  93. package/src/web/components/agents/index.ts +0 -4
  94. package/src/web/components/api/ApiDocsPage.tsx +0 -842
  95. package/src/web/components/auth/CreateAccountStep.tsx +0 -176
  96. package/src/web/components/auth/LoginPage.tsx +0 -91
  97. package/src/web/components/auth/index.ts +0 -2
  98. package/src/web/components/common/Icons.tsx +0 -250
  99. package/src/web/components/common/LoadingSpinner.tsx +0 -44
  100. package/src/web/components/common/Modal.tsx +0 -199
  101. package/src/web/components/common/Select.tsx +0 -97
  102. package/src/web/components/common/index.ts +0 -20
  103. package/src/web/components/connections/ConnectionsPage.tsx +0 -54
  104. package/src/web/components/connections/IntegrationsTab.tsx +0 -170
  105. package/src/web/components/connections/OverviewTab.tsx +0 -137
  106. package/src/web/components/connections/TriggersTab.tsx +0 -1346
  107. package/src/web/components/dashboard/Dashboard.tsx +0 -572
  108. package/src/web/components/dashboard/index.ts +0 -1
  109. package/src/web/components/index.ts +0 -21
  110. package/src/web/components/layout/ErrorBanner.tsx +0 -18
  111. package/src/web/components/layout/Header.tsx +0 -332
  112. package/src/web/components/layout/Sidebar.tsx +0 -231
  113. package/src/web/components/layout/index.ts +0 -3
  114. package/src/web/components/mcp/IntegrationsPanel.tsx +0 -857
  115. package/src/web/components/mcp/McpPage.tsx +0 -2515
  116. package/src/web/components/mcp/index.ts +0 -1
  117. package/src/web/components/meta-agent/MetaAgent.tsx +0 -245
  118. package/src/web/components/onboarding/OnboardingWizard.tsx +0 -404
  119. package/src/web/components/onboarding/index.ts +0 -1
  120. package/src/web/components/settings/SettingsPage.tsx +0 -2776
  121. package/src/web/components/settings/index.ts +0 -1
  122. package/src/web/components/skills/SkillsPage.tsx +0 -1200
  123. package/src/web/components/tasks/TasksPage.tsx +0 -1116
  124. package/src/web/components/tasks/index.ts +0 -1
  125. package/src/web/components/telemetry/TelemetryPage.tsx +0 -1129
  126. package/src/web/components/tests/TestsPage.tsx +0 -594
  127. package/src/web/components/threads/ThreadsPage.tsx +0 -315
  128. package/src/web/context/AuthContext.tsx +0 -242
  129. package/src/web/context/ProjectContext.tsx +0 -214
  130. package/src/web/context/TelemetryContext.tsx +0 -299
  131. package/src/web/context/ThemeContext.tsx +0 -90
  132. package/src/web/context/UIModeContext.tsx +0 -49
  133. package/src/web/context/index.ts +0 -12
  134. package/src/web/hooks/index.ts +0 -3
  135. package/src/web/hooks/useAgents.ts +0 -115
  136. package/src/web/hooks/useOnboarding.ts +0 -20
  137. package/src/web/hooks/useProviders.ts +0 -75
  138. package/src/web/icon.png +0 -0
  139. package/src/web/index.html +0 -16
  140. package/src/web/styles.css +0 -118
  141. package/src/web/themes.ts +0 -162
  142. package/src/web/types.ts +0 -298
@@ -1,315 +0,0 @@
1
- import React, { useState, useEffect, useCallback, useMemo } from "react";
2
- import { Chat, convertApiMessages } from "@apteva/apteva-kit";
3
- import { useAgentActivity, useAuth, useProjects, useTelemetryContext, useTheme } from "../../context";
4
- import type { TelemetryEvent } from "../../context";
5
- import type { Agent, Route } from "../../types";
6
-
7
- interface Thread {
8
- id: string;
9
- title?: string;
10
- created_at: string;
11
- updated_at: string;
12
- message_count?: number;
13
- agent_id: string;
14
- agent_name: string;
15
- }
16
-
17
- interface ThreadsPageProps {
18
- agents: Agent[];
19
- onNavigate?: (route: Route) => void;
20
- }
21
-
22
- export function ThreadsPage({ agents, onNavigate }: ThreadsPageProps) {
23
- const { theme } = useTheme();
24
- const { authFetch } = useAuth();
25
- const { currentProjectId } = useProjects();
26
- const { events: realtimeEvents, statusChangeCounter } = useTelemetryContext();
27
-
28
- const [threads, setThreads] = useState<Thread[]>([]);
29
- const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
30
- const [newChatAgent, setNewChatAgent] = useState<Agent | null>(null);
31
- const [initialMessages, setInitialMessages] = useState<any[]>([]);
32
- const [loadingThreads, setLoadingThreads] = useState(true);
33
- const [loadingMessages, setLoadingMessages] = useState(false);
34
- const [historicalActivities, setHistoricalActivities] = useState<TelemetryEvent[]>([]);
35
- const [showAgentPicker, setShowAgentPicker] = useState(false);
36
- const [newChatKey, setNewChatKey] = useState(0);
37
-
38
- const filteredAgents = useMemo(() => {
39
- if (currentProjectId === null) return agents;
40
- if (currentProjectId === "unassigned") return agents.filter(a => !a.projectId);
41
- return agents.filter(a => a.projectId === currentProjectId);
42
- }, [agents, currentProjectId]);
43
-
44
- const runningAgents = useMemo(() => filteredAgents.filter(a => a.status === "running"), [filteredAgents]);
45
- const agentIds = useMemo(() => new Set(filteredAgents.map(a => a.id)), [filteredAgents]);
46
-
47
- // Fetch consolidated threads
48
- const fetchThreads = useCallback(async () => {
49
- try {
50
- const projectParam = currentProjectId ? `?project_id=${encodeURIComponent(currentProjectId)}` : "";
51
- const [threadsRes, activityRes] = await Promise.all([
52
- authFetch(`/api/threads${projectParam}`).catch(() => null),
53
- authFetch(`/api/telemetry/events?type=thread_activity&limit=100${projectParam ? `&${projectParam}` : ""}`).catch(() => null),
54
- ]);
55
- if (threadsRes?.ok) {
56
- const data = await threadsRes.json();
57
- setThreads(data.threads || []);
58
- }
59
- if (activityRes?.ok) {
60
- const data = await activityRes.json();
61
- setHistoricalActivities(data.events || []);
62
- }
63
- } catch (e) {
64
- console.error("Failed to fetch threads:", e);
65
- } finally {
66
- setLoadingThreads(false);
67
- }
68
- }, [authFetch, currentProjectId]);
69
-
70
- useEffect(() => { fetchThreads(); }, [fetchThreads, statusChangeCounter]);
71
-
72
- useEffect(() => {
73
- const interval = setInterval(fetchThreads, 15000);
74
- return () => clearInterval(interval);
75
- }, [fetchThreads]);
76
-
77
- // Open an existing thread
78
- const openThread = useCallback(async (thread: Thread) => {
79
- setNewChatAgent(null);
80
- setLoadingMessages(true);
81
- setSelectedThread(thread);
82
- try {
83
- const res = await authFetch(`/api/agents/${thread.agent_id}/threads/${thread.id}/messages`);
84
- if (res.ok) {
85
- const data = await res.json();
86
- setInitialMessages(convertApiMessages(data.messages || []));
87
- } else {
88
- setInitialMessages([]);
89
- }
90
- } catch {
91
- setInitialMessages([]);
92
- }
93
- setLoadingMessages(false);
94
- }, [authFetch]);
95
-
96
- // Start a new conversation with an agent
97
- const startNewChat = (agent: Agent) => {
98
- setSelectedThread(null);
99
- setInitialMessages([]);
100
- setNewChatAgent(agent);
101
- setNewChatKey(k => k + 1);
102
- setShowAgentPicker(false);
103
- };
104
-
105
- // Merge real-time + historical activity
106
- const activities = useMemo(() => {
107
- const realtimeThreadEvents = realtimeEvents.filter(e => e.type === "thread_activity" && !e.data?.parent_id);
108
- const seen = new Set(realtimeThreadEvents.map(e => e.id));
109
- const merged = [...realtimeThreadEvents];
110
- for (const evt of historicalActivities) {
111
- if (!seen.has(evt.id) && !evt.data?.parent_id) { merged.push(evt); seen.add(evt.id); }
112
- }
113
- return merged
114
- .filter(e => agentIds.has(e.agent_id))
115
- .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
116
- .slice(0, 100);
117
- }, [realtimeEvents, historicalActivities, agentIds]);
118
-
119
- // Group activities by thread_id
120
- const activityByThread = useMemo(() => {
121
- const map = new Map<string, TelemetryEvent[]>();
122
- for (const evt of activities) {
123
- const tid = evt.thread_id || evt.data?.thread_id as string;
124
- if (tid) {
125
- if (!map.has(tid)) map.set(tid, []);
126
- map.get(tid)!.push(evt);
127
- }
128
- }
129
- return map;
130
- }, [activities]);
131
-
132
- const runningCount = runningAgents.length;
133
-
134
- // What's currently shown in chat
135
- const chatAgentId = selectedThread?.agent_id || newChatAgent?.id;
136
- const chatAgentName = selectedThread?.agent_name || newChatAgent?.name;
137
- const chatThreadId = selectedThread?.id;
138
- const chatKey = selectedThread
139
- ? `${selectedThread.agent_id}-${selectedThread.id}`
140
- : newChatAgent
141
- ? `new-${newChatAgent.id}-${newChatKey}`
142
- : null;
143
-
144
- return (
145
- <div className="flex-1 flex flex-col overflow-hidden">
146
- {/* Header */}
147
- <div className="px-6 pt-6 pb-4 shrink-0">
148
- <div className="flex items-center justify-between">
149
- <h1 className="text-xl font-semibold">Threads</h1>
150
- <span className="text-sm text-[var(--color-text-muted)]">
151
- {threads.length} threads from {runningCount} running agents
152
- </span>
153
- </div>
154
- </div>
155
-
156
- {/* Messenger layout: 1/4 threads | 3/4 chat */}
157
- <div className="flex-1 flex min-h-0 overflow-hidden">
158
- {/* Thread list — 1/4 */}
159
- <div className="w-1/4 min-w-[260px] max-w-[360px] flex flex-col overflow-hidden">
160
- {/* New conversation button */}
161
- <div className="p-2 shrink-0">
162
- <div className="relative">
163
- <button
164
- onClick={() => setShowAgentPicker(!showAgentPicker)}
165
- disabled={runningAgents.length === 0}
166
- className="w-full flex items-center justify-center gap-2 px-3 py-2 btn bg-[var(--color-accent-10)] text-[var(--color-accent)] text-sm font-medium hover:bg-[var(--color-accent-20)] transition disabled:opacity-30 disabled:cursor-not-allowed"
167
- >
168
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
169
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
170
- </svg>
171
- New conversation
172
- </button>
173
-
174
- {/* Agent picker dropdown */}
175
- {showAgentPicker && (
176
- <>
177
- <div className="fixed inset-0 z-40" onClick={() => setShowAgentPicker(false)} />
178
- <div className="absolute top-full left-0 right-0 mt-1 bg-[var(--color-surface)] card shadow-xl z-50 max-h-60 overflow-auto">
179
- {runningAgents.map(agent => (
180
- <button
181
- key={agent.id}
182
- onClick={() => startNewChat(agent)}
183
- className="w-full text-left px-3 py-2.5 hover:bg-[var(--color-surface-raised)] transition"
184
- >
185
- <p className="text-sm font-medium truncate">{agent.name}</p>
186
- <p className="text-[10px] text-[var(--color-text-faint)]">{agent.provider} · {agent.model}</p>
187
- </button>
188
- ))}
189
- </div>
190
- </>
191
- )}
192
- </div>
193
- </div>
194
-
195
- <div className="flex-1 overflow-auto px-2 pb-2">
196
- {loadingThreads ? (
197
- <div className="p-6 text-center text-[var(--color-text-faint)] text-sm">Loading threads...</div>
198
- ) : threads.length === 0 ? (
199
- <div className="p-6 text-center text-[var(--color-text-faint)] text-sm">
200
- <p>No threads yet</p>
201
- <p className="mt-1 text-[var(--color-text-faint)]">Start a conversation or wait for agents</p>
202
- </div>
203
- ) : (
204
- <div className="space-y-0.5">
205
- {threads.map(thread => (
206
- <ThreadRow
207
- key={`${thread.agent_id}-${thread.id}`}
208
- thread={thread}
209
- selected={selectedThread?.id === thread.id && selectedThread?.agent_id === thread.agent_id}
210
- activities={activityByThread.get(thread.id) || []}
211
- onSelect={() => openThread(thread)}
212
- />
213
- ))}
214
- </div>
215
- )}
216
- </div>
217
- </div>
218
-
219
- {/* Chat — 3/4 */}
220
- <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
221
- {chatAgentId && chatKey ? (
222
- loadingMessages ? (
223
- <div className="flex-1 flex items-center justify-center text-[var(--color-text-muted)]">Loading messages...</div>
224
- ) : (
225
- <Chat
226
- key={chatKey}
227
- agentId="default"
228
- apiUrl={`/api/agents/${chatAgentId}`}
229
- threadId={chatThreadId}
230
- initialMessages={initialMessages}
231
- placeholder={`Message ${chatAgentName}...`}
232
- headerTitle={chatAgentName}
233
- variant="terminal"
234
- theme={theme.id as "light" | "dark"}
235
- showHeader={true}
236
- />
237
- )
238
- ) : (
239
- <div className="flex-1 flex items-center justify-center">
240
- <div className="text-center text-[var(--color-text-faint)]">
241
- <svg className="w-12 h-12 mx-auto mb-3 text-[var(--color-border-light)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
242
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
243
- </svg>
244
- <p className="text-sm">Select a thread or start a new conversation</p>
245
- <p className="text-xs text-[var(--color-text-faint)] mt-1">Chat with any running agent</p>
246
- </div>
247
- </div>
248
- )}
249
- </div>
250
- </div>
251
- </div>
252
- );
253
- }
254
-
255
- // --- Thread Row ---
256
-
257
- function ThreadRow({ thread, selected, activities, onSelect }: {
258
- thread: Thread;
259
- selected: boolean;
260
- activities: TelemetryEvent[];
261
- onSelect: () => void;
262
- }) {
263
- const { isActive } = useAgentActivity(thread.agent_id);
264
- const latestActivity = activities[0];
265
- const activityText = latestActivity?.data?.activity as string | undefined;
266
-
267
- return (
268
- <button
269
- onClick={onSelect}
270
- className={`w-full text-left px-3 py-2.5 rounded-lg transition ${
271
- selected
272
- ? "bg-[var(--color-accent-10)]"
273
- : "hover:bg-[var(--color-bg-secondary)]"
274
- }`}
275
- >
276
- <div className="flex items-center justify-between gap-2 mb-1">
277
- <span className="text-sm font-medium truncate">
278
- {thread.title || `Thread ${thread.id.slice(0, 8)}`}
279
- </span>
280
- <span className="text-[10px] text-[var(--color-text-faint)] shrink-0">{timeAgo(thread.updated_at)}</span>
281
- </div>
282
- <div className="flex items-center gap-1.5">
283
- <span
284
- className={`w-1.5 h-1.5 rounded-full shrink-0 ${
285
- isActive ? "bg-green-400 animate-pulse" : "bg-[var(--color-scrollbar)]"
286
- }`}
287
- />
288
- <span className="text-[11px] text-[var(--color-accent)]">{thread.agent_name}</span>
289
- {thread.message_count != null && (
290
- <>
291
- <span className="text-[var(--color-border-light)]">&middot;</span>
292
- <span className="text-[10px] text-[var(--color-text-faint)]">{thread.message_count} msgs</span>
293
- </>
294
- )}
295
- </div>
296
- {activityText && (
297
- <p className="text-[11px] text-[var(--color-text-faint)] truncate mt-1">{activityText}</p>
298
- )}
299
- </button>
300
- );
301
- }
302
-
303
- // --- Helpers ---
304
-
305
- function timeAgo(timestamp: string): string {
306
- const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000);
307
- if (seconds < 5) return "just now";
308
- if (seconds < 60) return `${seconds}s ago`;
309
- const minutes = Math.floor(seconds / 60);
310
- if (minutes < 60) return `${minutes}m ago`;
311
- const hours = Math.floor(minutes / 60);
312
- if (hours < 24) return `${hours}h ago`;
313
- const days = Math.floor(hours / 24);
314
- return `${days}d ago`;
315
- }
@@ -1,242 +0,0 @@
1
- import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, 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
- isDev: boolean;
13
- user?: User;
14
- }
15
-
16
- interface AuthContextValue {
17
- user: User | null;
18
- isAuthenticated: boolean;
19
- isLoading: boolean;
20
- hasUsers: boolean | null;
21
- isDev: boolean;
22
- accessToken: string | null;
23
- onboardingComplete: boolean | null;
24
- setOnboardingComplete: (v: boolean) => void;
25
- login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
26
- logout: () => Promise<void>;
27
- refreshToken: () => Promise<boolean>;
28
- checkAuth: () => Promise<void>;
29
- authFetch: (url: string, options?: RequestInit) => Promise<Response>;
30
- }
31
-
32
- const AuthContext = createContext<AuthContextValue | null>(null);
33
-
34
- export function useAuth(): AuthContextValue {
35
- const context = useContext(AuthContext);
36
- if (!context) {
37
- throw new Error("useAuth must be used within an AuthProvider");
38
- }
39
- return context;
40
- }
41
-
42
- interface AuthProviderProps {
43
- children: ReactNode;
44
- }
45
-
46
- export function AuthProvider({ children }: AuthProviderProps) {
47
- const [user, setUser] = useState<User | null>(null);
48
- const [accessToken, setAccessToken] = useState<string | null>(null);
49
- const [isLoading, setIsLoading] = useState(true);
50
- const [hasUsers, setHasUsers] = useState<boolean | null>(null);
51
- const [isDev, setIsDev] = useState(false);
52
- const [onboardingComplete, setOnboardingComplete] = useState<boolean | null>(null);
53
-
54
- // Refs to track state without causing re-renders
55
- const tokenRef = useRef<string | null>(null);
56
- const refreshingRef = useRef(false);
57
- const initializedRef = useRef(false);
58
-
59
- // Helper to set token in both state and ref
60
- const updateToken = useCallback((token: string | null) => {
61
- tokenRef.current = token;
62
- setAccessToken(token);
63
- }, []);
64
-
65
- // Internal refresh function - prevents concurrent refreshes
66
- const refreshTokenInternal = useCallback(async (): Promise<boolean> => {
67
- // Prevent concurrent refresh calls
68
- if (refreshingRef.current) {
69
- return false;
70
- }
71
- refreshingRef.current = true;
72
-
73
- try {
74
- const res = await fetch("/api/auth/refresh", {
75
- method: "POST",
76
- credentials: "include",
77
- });
78
-
79
- if (!res.ok) {
80
- return false;
81
- }
82
-
83
- const data = await res.json();
84
- updateToken(data.accessToken);
85
-
86
- // User info + onboarding included in refresh response to avoid extra round trip
87
- if (data.user) {
88
- setUser(data.user);
89
- }
90
- if (data.onboarding) {
91
- setOnboardingComplete(data.onboarding.completed || data.onboarding.has_any_keys);
92
- }
93
-
94
- return !!data.user;
95
- } catch (e) {
96
- console.error("Token refresh failed:", e);
97
- return false;
98
- } finally {
99
- refreshingRef.current = false;
100
- }
101
- }, [updateToken]);
102
-
103
- // Check auth status
104
- const checkAuth = useCallback(async () => {
105
- try {
106
- const token = tokenRef.current;
107
- const res = await fetch("/api/auth/check", {
108
- headers: token ? { Authorization: `Bearer ${token}` } : {},
109
- });
110
- const data: AuthStatus & { onboarding?: { completed: boolean; has_any_keys: boolean } } = await res.json();
111
-
112
- setHasUsers(data.hasUsers);
113
- setIsDev(data.isDev ?? false);
114
-
115
- // Extract onboarding status (piggybacks on auth check to avoid extra round trip)
116
- if (data.onboarding) {
117
- setOnboardingComplete(data.onboarding.completed || data.onboarding.has_any_keys);
118
- }
119
-
120
- if (data.authenticated && data.user) {
121
- setUser(data.user as User);
122
- } else {
123
- setUser(null);
124
- // Try to refresh if we have users (meaning there might be a cookie)
125
- if (data.hasUsers) {
126
- const refreshed = await refreshTokenInternal();
127
- if (!refreshed) {
128
- updateToken(null);
129
- }
130
- }
131
- }
132
- } catch (e) {
133
- console.error("Auth check failed:", e);
134
- setUser(null);
135
- updateToken(null);
136
- } finally {
137
- setIsLoading(false);
138
- }
139
- }, [refreshTokenInternal, updateToken]);
140
-
141
- // Login
142
- const login = useCallback(async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
143
- try {
144
- const res = await fetch("/api/auth/login", {
145
- method: "POST",
146
- headers: { "Content-Type": "application/json" },
147
- credentials: "include",
148
- body: JSON.stringify({ username, password }),
149
- });
150
-
151
- const data = await res.json();
152
-
153
- if (!res.ok) {
154
- return { success: false, error: data.error || "Login failed" };
155
- }
156
-
157
- updateToken(data.accessToken);
158
- setUser(data.user);
159
- setHasUsers(true);
160
-
161
- return { success: true };
162
- } catch (e) {
163
- console.error("Login failed:", e);
164
- return { success: false, error: "Login failed" };
165
- }
166
- }, [updateToken]);
167
-
168
- // Logout
169
- const logout = useCallback(async () => {
170
- try {
171
- const token = tokenRef.current;
172
- await fetch("/api/auth/logout", {
173
- method: "POST",
174
- credentials: "include",
175
- headers: token ? { Authorization: `Bearer ${token}` } : {},
176
- });
177
- } catch (e) {
178
- console.error("Logout failed:", e);
179
- } finally {
180
- setUser(null);
181
- updateToken(null);
182
- }
183
- }, [updateToken]);
184
-
185
- // Authenticated fetch wrapper - uses ref for latest token
186
- const authFetch = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
187
- const headers = new Headers(options.headers);
188
- const token = tokenRef.current;
189
- if (token) {
190
- headers.set("Authorization", `Bearer ${token}`);
191
- }
192
- return fetch(url, { ...options, headers });
193
- }, []);
194
-
195
- // Public refresh function
196
- const refreshToken = useCallback(async (): Promise<boolean> => {
197
- return refreshTokenInternal();
198
- }, [refreshTokenInternal]);
199
-
200
- // Check auth on mount - only once
201
- useEffect(() => {
202
- if (initializedRef.current) return;
203
- initializedRef.current = true;
204
- checkAuth();
205
- }, [checkAuth]);
206
-
207
- // Set up token refresh interval
208
- useEffect(() => {
209
- if (!accessToken) return;
210
-
211
- // Refresh token 1 minute before expiry (tokens last 15 min)
212
- const refreshInterval = setInterval(() => {
213
- refreshTokenInternal();
214
- }, 14 * 60 * 1000); // 14 minutes
215
-
216
- return () => clearInterval(refreshInterval);
217
- }, [accessToken, refreshTokenInternal]);
218
-
219
- const value = useMemo<AuthContextValue>(() => ({
220
- user,
221
- isAuthenticated: !!user,
222
- isLoading,
223
- hasUsers,
224
- isDev,
225
- accessToken,
226
- onboardingComplete,
227
- setOnboardingComplete,
228
- login,
229
- logout,
230
- refreshToken,
231
- checkAuth,
232
- authFetch,
233
- }), [user, isLoading, hasUsers, isDev, accessToken, onboardingComplete, setOnboardingComplete, login, logout, refreshToken, checkAuth, authFetch]);
234
-
235
- return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
236
- }
237
-
238
- // Hook to get auth headers for API calls
239
- export function useAuthHeaders(): Record<string, string> {
240
- const { accessToken } = useAuth();
241
- return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
242
- }