@aion0/forge 0.10.40 → 0.10.42

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 (61) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +4 -7
  3. package/app/api/bridge-info/route.ts +34 -0
  4. package/app/api/connectors/[id]/test/route.ts +14 -0
  5. package/app/api/connectors/import-config-template/route.ts +103 -13
  6. package/app/api/enterprise-keys/route.ts +204 -0
  7. package/app/api/marketplace/sync-all/route.ts +28 -0
  8. package/app/api/monitor/route.ts +29 -6
  9. package/app/api/onboarding/route.ts +920 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/app/chat/page.tsx +8 -5
  13. package/bin/forge-server.mjs +98 -1
  14. package/cli/mw.mjs +16 -6
  15. package/cli/mw.ts +19 -6
  16. package/components/ConnectorsPanel.tsx +85 -13
  17. package/components/CraftTerminal.tsx +12 -3
  18. package/components/Dashboard.tsx +55 -17
  19. package/components/DocTerminal.tsx +12 -6
  20. package/components/EnterpriseBadge.tsx +420 -0
  21. package/components/LoginStatusPanel.tsx +15 -1
  22. package/components/OnboardingWizard.tsx +418 -31
  23. package/components/SettingsModal.tsx +382 -63
  24. package/components/SkillsPanel.tsx +116 -91
  25. package/components/WebTerminal.tsx +36 -13
  26. package/dev-test.sh +34 -1
  27. package/install.sh +29 -2
  28. package/lib/agents/claude-adapter.ts +18 -4
  29. package/lib/agents/index.ts +33 -4
  30. package/lib/auth/login-status.ts +14 -0
  31. package/lib/chat/agent-loop.ts +23 -1
  32. package/lib/chat/llm/anthropic.ts +6 -1
  33. package/lib/chat/protocols/http.ts +15 -2
  34. package/lib/chat/tool-dispatcher.ts +163 -1
  35. package/lib/connectors/registry.ts +69 -4
  36. package/lib/connectors/sync.ts +536 -138
  37. package/lib/connectors/test-runner.ts +21 -3
  38. package/lib/connectors/types.ts +36 -4
  39. package/lib/connectors/wizard-template.ts +161 -0
  40. package/lib/dirs.ts +5 -0
  41. package/lib/enterprise-known.ts +34 -0
  42. package/lib/enterprise-secret.ts +87 -0
  43. package/lib/enterprise.ts +208 -0
  44. package/lib/help-docs/00-overview.md +12 -0
  45. package/lib/help-docs/01-settings.md +47 -1
  46. package/lib/help-docs/17-connectors.md +25 -22
  47. package/lib/help-docs/CLAUDE.md +1 -0
  48. package/lib/init.ts +13 -6
  49. package/lib/marketplace-sync.ts +70 -0
  50. package/lib/memory/temper-provision.ts +92 -0
  51. package/lib/pipeline-gc.ts +5 -2
  52. package/lib/pipeline.ts +26 -21
  53. package/lib/plugins/templates.ts +76 -3
  54. package/lib/projects.ts +85 -0
  55. package/lib/settings.ts +10 -0
  56. package/lib/telegram-bot.ts +14 -2
  57. package/lib/workflow-marketplace.ts +174 -108
  58. package/package.json +1 -1
  59. package/{middleware.ts → proxy.ts} +2 -1
  60. package/src/core/db/database.ts +8 -2
  61. package/templates/connector-config-template.json +0 -7
@@ -30,11 +30,17 @@ export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent
30
30
  fetch('/api/settings').then(r => r.json())
31
31
  .then((s: any) => { if (s.skipPermissions) skipPermRef.current = true; })
32
32
  .catch(() => {});
33
+ // Resolve via ?resolve= so derived agents (forti-k2 base: claude
34
+ // with no own path) inherit the base's absolute path. The plain
35
+ // /api/agents list returns the row's literal `path`, which is
36
+ // empty for derived agents and gives a useless fallback.
33
37
  fetch('/api/agents').then(r => r.json())
34
- .then(data => {
38
+ .then(async (data: any) => {
35
39
  const targetId = agent || data.defaultAgent || 'claude';
36
- const found = (data.agents || []).find((a: any) => a.id === targetId);
37
- if (found?.path) agentCmdRef.current = found.path;
40
+ try {
41
+ const info = await fetch(`/api/agents?resolve=${encodeURIComponent(targetId)}`).then(r => r.ok ? r.json() : null);
42
+ if (info?.cliCmd) agentCmdRef.current = info.cliCmd;
43
+ } catch {}
38
44
  })
39
45
  .catch(() => {});
40
46
  }, []);
@@ -97,7 +103,7 @@ export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent
97
103
  setTimeout(() => {
98
104
  if (socket.readyState === WebSocket.OPEN) {
99
105
  const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
100
- socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" && ${agentCmdRef.current} -c${sf}\n` }));
106
+ socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" && "${agentCmdRef.current}" -c${sf}\n` }));
101
107
  }
102
108
  }, 300);
103
109
  }
@@ -166,13 +172,13 @@ export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent
166
172
  </span>
167
173
  <div className="ml-auto flex items-center gap-1">
168
174
  <button
169
- onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && ${agentCmdRef.current}${sf}`); }}
175
+ onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && "${agentCmdRef.current}"${sf}`); }}
170
176
  className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:bg-[#2a2a4a] rounded"
171
177
  >
172
178
  New
173
179
  </button>
174
180
  <button
175
- onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && ${agentCmdRef.current} -c${sf}`); }}
181
+ onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && "${agentCmdRef.current}" -c${sf}`); }}
176
182
  className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
177
183
  >
178
184
  Resume
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Dashboard top-nav chip listing active enterprise marketplace sources.
3
+ *
4
+ * - No keys configured → renders a single dim '+ Enterprise' button
5
+ * that opens the Add-tenant form. Most non-enterprise users won't
6
+ * even notice it.
7
+ * - 1+ keys → '🔒 Fortinet' (single) or '🔒 Fortinet +1'
8
+ * (collapsed badge). Click opens a popover with the full list,
9
+ * per-tenant Re-sync / Reinstall / Remove buttons, sync status,
10
+ * and an inline 'Add another tenant' input.
11
+ *
12
+ * Replaces the old behaviour of jumping into Settings — most enterprise
13
+ * lifecycle actions (resync, reinstall, add, remove, see status) now
14
+ * live in this single popover, which is where users instinctively look
15
+ * once they've spotted the 🔒 chip.
16
+ */
17
+
18
+ import { useEffect, useRef, useState } from 'react';
19
+
20
+ interface SourceView {
21
+ tenant_id: string;
22
+ display_name: string;
23
+ repo_url: string;
24
+ priority: number;
25
+ pat_preview: string;
26
+ last_sync?: { ok: boolean; error?: string; fetched_at: string } | null;
27
+ departments?: Array<{ id: string; display_name: string; has_template?: boolean }>;
28
+ active_dept?: string | null;
29
+ }
30
+
31
+ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { onOpenSettings: () => void }) {
32
+ const [sources, setSources] = useState<SourceView[]>([]);
33
+ const [open, setOpen] = useState(false);
34
+ const [busy, setBusy] = useState<'resync' | 'reinstall' | string | null>(null);
35
+ const [status, setStatus] = useState('');
36
+ const [error, setError] = useState('');
37
+ const [showAddInline, setShowAddInline] = useState(false);
38
+ const [newKey, setNewKey] = useState('');
39
+ // Edit-key flow: `editTenant` is the tenant_id currently being edited
40
+ // (null when not editing). `editKey` is the new PAT/long-form key the
41
+ // user is typing. Kept separate from add-flow state so the row's
42
+ // pencil button doesn't collide with the bottom + Add tenant input.
43
+ const [editTenant, setEditTenant] = useState<string | null>(null);
44
+ const [editKey, setEditKey] = useState('');
45
+ const popoverRef = useRef<HTMLDivElement>(null);
46
+
47
+ const load = async () => {
48
+ try {
49
+ const r = await fetch('/api/enterprise-keys');
50
+ if (!r.ok) return;
51
+ const data = await r.json();
52
+ setSources((data?.sources || []) as SourceView[]);
53
+ } catch {
54
+ // Network blip — leave previous state.
55
+ }
56
+ };
57
+
58
+ useEffect(() => {
59
+ load();
60
+ const onFocus = () => { load(); };
61
+ window.addEventListener('focus', onFocus);
62
+ return () => window.removeEventListener('focus', onFocus);
63
+ }, []);
64
+
65
+ // Close popover on outside click.
66
+ useEffect(() => {
67
+ if (!open) return;
68
+ const onClick = (e: MouseEvent) => {
69
+ if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
70
+ setOpen(false);
71
+ }
72
+ };
73
+ window.addEventListener('mousedown', onClick);
74
+ return () => window.removeEventListener('mousedown', onClick);
75
+ }, [open]);
76
+
77
+ const reset = () => { setStatus(''); setError(''); };
78
+
79
+ // Single Re-sync: pulls registry.json AND re-fetches installed
80
+ // connector manifests when their version changes. The old
81
+ // "Reinstall all" button (force-overwrite regardless of version)
82
+ // was removed — too many footguns; rare cases can re-run the wizard
83
+ // or use the per-row ↻ in the connectors panel.
84
+ const handleResync = async () => {
85
+ reset();
86
+ setBusy('resync');
87
+ try {
88
+ const r = await fetch('/api/enterprise-keys', {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ action: 'resync', refreshInstalled: true }),
92
+ });
93
+ const data = await r.json();
94
+ if (!data.ok) { setError(data.error || 'resync failed'); }
95
+ else {
96
+ const result = data.result || {};
97
+ const okN = (result.sources || []).filter((s: any) => s.ok).length;
98
+ const total = (result.sources || []).length;
99
+ const refreshed = result.manifests_refreshed || 0;
100
+ setStatus(`✓ Re-synced ${okN}/${total} sources${refreshed ? ` · refreshed ${refreshed} manifests` : ''}`);
101
+ }
102
+ await load();
103
+ } catch (e) {
104
+ setError(e instanceof Error ? e.message : String(e));
105
+ } finally {
106
+ setBusy(null);
107
+ }
108
+ };
109
+
110
+ const handleEdit = async (tenant_id: string, display_name: string) => {
111
+ const key = editKey.trim();
112
+ if (!key) return;
113
+ reset();
114
+ setBusy(`edit:${tenant_id}`);
115
+ try {
116
+ const r = await fetch(`/api/enterprise-keys?tenant_id=${encodeURIComponent(tenant_id)}`, {
117
+ method: 'PATCH',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify({ key }),
120
+ });
121
+ const data = await r.json();
122
+ if (!data.ok) setError(data.error || 'edit failed');
123
+ else {
124
+ setStatus(`✓ Updated ${display_name} — key rotated`);
125
+ setEditTenant(null);
126
+ setEditKey('');
127
+ }
128
+ await load();
129
+ } catch (e) {
130
+ setError(e instanceof Error ? e.message : String(e));
131
+ } finally {
132
+ setBusy(null);
133
+ }
134
+ };
135
+
136
+ const handleRemove = async (tenant_id: string, display_name: string) => {
137
+ if (!confirm(`Remove enterprise source "${display_name}"?\n\nInstalled connectors stay on disk; they just stop receiving updates.`)) return;
138
+ reset();
139
+ setBusy(`remove:${tenant_id}`);
140
+ try {
141
+ const r = await fetch(`/api/enterprise-keys?tenant_id=${encodeURIComponent(tenant_id)}`, { method: 'DELETE' });
142
+ const data = await r.json();
143
+ if (!data.ok) setError(data.error || 'remove failed');
144
+ else setStatus(`✓ Removed ${display_name}`);
145
+ await load();
146
+ } catch (e) {
147
+ setError(e instanceof Error ? e.message : String(e));
148
+ } finally {
149
+ setBusy(null);
150
+ }
151
+ };
152
+
153
+ const handleAdd = async () => {
154
+ const key = newKey.trim();
155
+ if (!key) return;
156
+ reset();
157
+ setBusy('add');
158
+ try {
159
+ const r = await fetch('/api/enterprise-keys', {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify({ key }),
163
+ });
164
+ const data = await r.json();
165
+ if (!data.ok) setError(data.error || 'failed to add key');
166
+ else {
167
+ setNewKey('');
168
+ setShowAddInline(false);
169
+ // E4: when the new tenant landed a wizard template on disk, hand
170
+ // off to the onboarding wizard scoped to this source so the user
171
+ // can apply its defaults right away. Dispatched via the existing
172
+ // forge:open-onboarding event so SettingsModal isn't a required
173
+ // intermediate. Falls back to the status pill when no template
174
+ // is available (public-only tenant).
175
+ if (data.has_wizard_template && data.source_id) {
176
+ setStatus(`✓ Added ${data.tenant_id} — opening wizard…`);
177
+ window.dispatchEvent(new CustomEvent('forge:open-onboarding', {
178
+ detail: { source_id: data.source_id },
179
+ }));
180
+ setOpen(false);
181
+ } else {
182
+ setStatus(`✓ Added ${data.tenant_id} — marketplace synced`);
183
+ }
184
+ }
185
+ await load();
186
+ } catch (e) {
187
+ setError(e instanceof Error ? e.message : String(e));
188
+ } finally {
189
+ setBusy(null);
190
+ }
191
+ };
192
+
193
+ // When there are no keys, render a compact "+ Enterprise" chip so the
194
+ // entry point still exists for power users. Non-enterprise people can
195
+ // ignore it.
196
+ if (sources.length === 0) {
197
+ return (
198
+ <div className="relative" ref={popoverRef}>
199
+ <button
200
+ onClick={() => { setOpen(v => !v); setShowAddInline(true); }}
201
+ title="Add a private enterprise marketplace key"
202
+ className="text-[10px] px-2 py-0.5 rounded border border-dashed border-[var(--text-secondary)]/30 text-[var(--text-secondary)] hover:border-[var(--text-primary)]/40 hover:text-[var(--text-primary)] transition-colors"
203
+ >
204
+ + Enterprise
205
+ </button>
206
+ {open && <AddTenantPopover newKey={newKey} setNewKey={setNewKey} onSubmit={handleAdd} onCancel={() => setOpen(false)} busy={busy === 'add'} error={error} status={status} />}
207
+ </div>
208
+ );
209
+ }
210
+
211
+ const first = sources[0];
212
+ const extra = sources.length - 1;
213
+
214
+ return (
215
+ <div className="relative" ref={popoverRef}>
216
+ <button
217
+ onClick={() => setOpen(v => !v)}
218
+ title="Click for enterprise actions (re-sync, reinstall, add tenant)"
219
+ className="text-[10px] px-2 py-0.5 rounded bg-amber-500/15 text-amber-400 hover:bg-amber-500/25 transition-colors flex items-center gap-1"
220
+ >
221
+ <span>🔒</span>
222
+ <span className="font-medium">{first.display_name}</span>
223
+ {extra > 0 && <span className="opacity-70">+{extra}</span>}
224
+ <span className="opacity-60 text-[9px]">▾</span>
225
+ </button>
226
+ {open && (
227
+ <div className="absolute left-0 top-full mt-1 w-[360px] z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg p-2 space-y-2">
228
+ <div className="text-[10px] text-[var(--text-secondary)] uppercase tracking-wider px-1">
229
+ Enterprise tenants ({sources.length})
230
+ </div>
231
+
232
+ {/* Per-tenant rows */}
233
+ <div className="space-y-1">
234
+ {sources.map(s => {
235
+ const sync = s.last_sync;
236
+ const ok = sync?.ok;
237
+ const failed = sync && !sync.ok;
238
+ return (
239
+ <div key={s.tenant_id} className="border border-[var(--border)] rounded px-2 py-1.5 space-y-1">
240
+ <div className="flex items-center gap-1.5">
241
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400 shrink-0">
242
+ 🔒 {s.display_name}
243
+ </span>
244
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto">
245
+ priority {s.priority}
246
+ </span>
247
+ </div>
248
+ <div className="text-[9px] font-mono text-[var(--text-secondary)] truncate" title={s.repo_url}>
249
+ {s.repo_url}
250
+ </div>
251
+ <div className="text-[9px] text-[var(--text-secondary)] flex items-center gap-2">
252
+ <span>PAT {s.pat_preview}</span>
253
+ {ok && <span className="text-emerald-500">● synced</span>}
254
+ {failed && <span className="text-red-400">✗ failed</span>}
255
+ {!sync && <span className="italic">(not yet)</span>}
256
+ </div>
257
+ {/* Dept lives in Settings → Identity (profile field).
258
+ No longer shown on the tenant row. */}
259
+ {failed && sync?.error && (
260
+ <div className="text-[9px] text-red-300 border border-red-500/30 bg-red-500/5 rounded px-1.5 py-1 leading-snug whitespace-pre-wrap break-words">
261
+ {sync.error}
262
+ </div>
263
+ )}
264
+ {editTenant === s.tenant_id ? (
265
+ <div className="space-y-1 pt-1 border-t border-[var(--border)]">
266
+ <input
267
+ type="password"
268
+ value={editKey}
269
+ onChange={(e) => setEditKey(e.target.value)}
270
+ placeholder="new PAT or full <pat>@<repo>"
271
+ className="w-full text-[10px] px-1.5 py-1 rounded border border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
272
+ autoFocus
273
+ />
274
+ <div className="flex gap-1 justify-end">
275
+ <button
276
+ onClick={() => { setEditTenant(null); setEditKey(''); }}
277
+ className="text-[9px] px-1.5 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
278
+ >
279
+ Cancel
280
+ </button>
281
+ <button
282
+ onClick={() => handleEdit(s.tenant_id, s.display_name)}
283
+ disabled={!editKey.trim() || busy === `edit:${s.tenant_id}`}
284
+ className="text-[9px] px-1.5 py-0.5 rounded border border-[var(--accent)]/40 text-[var(--accent)] hover:bg-[var(--accent)]/10 disabled:opacity-40"
285
+ >
286
+ {busy === `edit:${s.tenant_id}` ? 'Saving…' : 'Save'}
287
+ </button>
288
+ </div>
289
+ </div>
290
+ ) : (
291
+ <div className="flex justify-end gap-1">
292
+ <button
293
+ onClick={() => { setEditTenant(s.tenant_id); setEditKey(''); reset(); }}
294
+ className="text-[9px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
295
+ title="Rotate the PAT for this tenant in place"
296
+ >
297
+ ✎ Edit
298
+ </button>
299
+ <button
300
+ onClick={() => handleRemove(s.tenant_id, s.display_name)}
301
+ disabled={busy === `remove:${s.tenant_id}`}
302
+ className="text-[9px] px-1.5 py-0.5 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 disabled:opacity-40"
303
+ >
304
+ Remove
305
+ </button>
306
+ </div>
307
+ )}
308
+ </div>
309
+ );
310
+ })}
311
+ </div>
312
+
313
+ {/* Actions (apply to ALL tenants) */}
314
+ <div className="flex gap-1.5 pt-1 border-t border-[var(--border)]">
315
+ <button
316
+ onClick={() => handleResync()}
317
+ disabled={!!busy}
318
+ className="text-[10px] px-2 py-1 rounded border border-[var(--border)] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:opacity-40"
319
+ title="Re-fetch registry.json from every source AND re-pull installed connector manifests when their version changed."
320
+ >
321
+ {busy === 'resync' ? 'Syncing…' : '↻ Re-sync'}
322
+ </button>
323
+ {!showAddInline && (
324
+ <button
325
+ onClick={() => setShowAddInline(true)}
326
+ className="text-[10px] px-2 py-1 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
327
+ >
328
+ + Add tenant
329
+ </button>
330
+ )}
331
+ </div>
332
+
333
+ {/* Inline add */}
334
+ {showAddInline && (
335
+ <div className="border-t border-[var(--border)] pt-2 space-y-1.5">
336
+ <label className="text-[9px] text-[var(--text-secondary)] block">
337
+ Add another enterprise tenant
338
+ </label>
339
+ <input
340
+ type="text"
341
+ placeholder="acme:github_pat_xxx"
342
+ value={newKey}
343
+ onChange={(e) => setNewKey(e.target.value)}
344
+ onKeyDown={(e) => { if (e.key === 'Enter' && !busy) handleAdd(); }}
345
+ className="w-full text-[10px] font-mono px-2 py-1 rounded bg-[var(--bg-primary)] border border-[var(--border)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50"
346
+ />
347
+ <div className="flex gap-1.5">
348
+ <button
349
+ onClick={handleAdd}
350
+ disabled={busy === 'add' || !newKey.trim()}
351
+ className="text-[10px] px-2 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90 disabled:opacity-40"
352
+ >
353
+ {busy === 'add' ? 'Adding…' : 'Add'}
354
+ </button>
355
+ <button
356
+ onClick={() => { setShowAddInline(false); setNewKey(''); }}
357
+ className="text-[10px] px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
358
+ >
359
+ Cancel
360
+ </button>
361
+ </div>
362
+ </div>
363
+ )}
364
+
365
+ {/* Status / error */}
366
+ {error && (
367
+ <div className="text-[9px] text-red-400 border border-red-500/30 bg-red-500/5 rounded px-1.5 py-1">
368
+ {error}
369
+ </div>
370
+ )}
371
+ {status && !error && (
372
+ <div className="text-[9px] text-emerald-400">{status}</div>
373
+ )}
374
+ </div>
375
+ )}
376
+ </div>
377
+ );
378
+ }
379
+
380
+ function AddTenantPopover({
381
+ newKey, setNewKey, onSubmit, onCancel, busy, error, status,
382
+ }: {
383
+ newKey: string;
384
+ setNewKey: (s: string) => void;
385
+ onSubmit: () => void;
386
+ onCancel: () => void;
387
+ busy: boolean;
388
+ error: string;
389
+ status: string;
390
+ }) {
391
+ return (
392
+ <div className="absolute left-0 top-full mt-1 w-[320px] z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg p-2 space-y-1.5">
393
+ <div className="text-[10px] text-[var(--text-secondary)]">
394
+ Add a private enterprise marketplace key. Forge will sync immediately after.
395
+ </div>
396
+ <input
397
+ type="text"
398
+ placeholder="fortinet:github_pat_xxx"
399
+ value={newKey}
400
+ onChange={(e) => setNewKey(e.target.value)}
401
+ onKeyDown={(e) => { if (e.key === 'Enter' && !busy) onSubmit(); }}
402
+ className="w-full text-[10px] font-mono px-2 py-1 rounded bg-[var(--bg-primary)] border border-[var(--border)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50"
403
+ />
404
+ <div className="flex gap-1.5">
405
+ <button
406
+ onClick={onSubmit}
407
+ disabled={busy || !newKey.trim()}
408
+ className="text-[10px] px-2 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90 disabled:opacity-40"
409
+ >
410
+ {busy ? 'Adding…' : 'Add'}
411
+ </button>
412
+ <button onClick={onCancel} className="text-[10px] px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
413
+ Cancel
414
+ </button>
415
+ </div>
416
+ {error && <div className="text-[9px] text-red-400">{error}</div>}
417
+ {status && !error && <div className="text-[9px] text-emerald-400">{status}</div>}
418
+ </div>
419
+ );
420
+ }
@@ -57,7 +57,21 @@ export default function LoginStatusPanel({ onClose }: { onClose: () => void }) {
57
57
  .catch(() => {});
58
58
  }, []);
59
59
 
60
- useEffect(() => { loadCached(); }, [loadCached]);
60
+ // On mount: force a fresh probe (POST) so the panel never shows a stale
61
+ // cached 401 after the user fixed the token in another tab. After that,
62
+ // poll the cache (GET) every 5s — refresh-via-curl actions update the
63
+ // cache and we pick them up without re-probing every poll cycle.
64
+ useEffect(() => {
65
+ (async () => {
66
+ try {
67
+ const r = await fetch('/api/login-status', { method: 'POST' });
68
+ const j = await r.json();
69
+ setRows(j.rows || []);
70
+ } catch { loadCached(); }
71
+ })();
72
+ const t = setInterval(loadCached, 5000);
73
+ return () => clearInterval(t);
74
+ }, [loadCached]);
61
75
 
62
76
  const checkAll = async () => {
63
77
  setBusyAll(true);