@aion0/forge 0.10.39 → 0.10.41

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 (59) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +83 -6
  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 +897 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/bin/forge-server.mjs +189 -30
  13. package/cli/mw.mjs +16 -6
  14. package/cli/mw.ts +19 -6
  15. package/components/ConnectorsPanel.tsx +85 -13
  16. package/components/CraftTerminal.tsx +12 -3
  17. package/components/Dashboard.tsx +55 -17
  18. package/components/DocTerminal.tsx +12 -6
  19. package/components/EnterpriseBadge.tsx +420 -0
  20. package/components/LoginStatusPanel.tsx +15 -1
  21. package/components/OnboardingWizard.tsx +418 -31
  22. package/components/SettingsModal.tsx +382 -63
  23. package/components/SkillsPanel.tsx +116 -91
  24. package/components/WebTerminal.tsx +36 -13
  25. package/dev-test.sh +34 -1
  26. package/install.sh +29 -2
  27. package/lib/agents/claude-adapter.ts +18 -4
  28. package/lib/agents/index.ts +33 -4
  29. package/lib/auth/login-status.ts +14 -0
  30. package/lib/chat/agent-loop.ts +23 -1
  31. package/lib/chat/protocols/http.ts +15 -2
  32. package/lib/chat/tool-dispatcher.ts +163 -1
  33. package/lib/connectors/registry.ts +69 -4
  34. package/lib/connectors/sync.ts +536 -138
  35. package/lib/connectors/test-runner.ts +21 -3
  36. package/lib/connectors/types.ts +36 -4
  37. package/lib/connectors/wizard-template.ts +161 -0
  38. package/lib/dirs.ts +5 -0
  39. package/lib/enterprise-known.ts +34 -0
  40. package/lib/enterprise-secret.ts +87 -0
  41. package/lib/enterprise.ts +208 -0
  42. package/lib/help-docs/00-overview.md +12 -0
  43. package/lib/help-docs/01-settings.md +47 -1
  44. package/lib/help-docs/17-connectors.md +25 -22
  45. package/lib/help-docs/CLAUDE.md +1 -0
  46. package/lib/init.ts +13 -6
  47. package/lib/marketplace-sync.ts +70 -0
  48. package/lib/memory/temper-provision.ts +92 -0
  49. package/lib/pipeline-gc.ts +5 -2
  50. package/lib/pipeline.ts +26 -21
  51. package/lib/plugins/templates.ts +76 -3
  52. package/lib/projects.ts +85 -0
  53. package/lib/settings.ts +10 -0
  54. package/lib/telegram-bot.ts +14 -2
  55. package/lib/workflow-marketplace.ts +174 -108
  56. package/package.json +1 -1
  57. package/{middleware.ts → proxy.ts} +2 -1
  58. package/src/core/db/database.ts +8 -2
  59. package/templates/connector-config-template.json +0 -7
@@ -26,6 +26,7 @@ const SettingsModal = lazy(() => import('./SettingsModal'));
26
26
  const MonitorPanel = lazy(() => import('./MonitorPanel'));
27
27
  const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
28
28
  const ActivityPanel = lazy(() => import('./ActivityPanel'));
29
+ const EnterpriseBadge = lazy(() => import('./EnterpriseBadge'));
29
30
  const WorkspaceView = lazy(() => import('./WorkspaceView'));
30
31
  // WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
31
32
  import { OnboardingBanner, OnboardingDrawer } from './OnboardingWizard';
@@ -136,12 +137,27 @@ export default function Dashboard({ user }: { user: any }) {
136
137
  const [showSettings, setShowSettings] = useState(false);
137
138
  const [needsOnboarding, setNeedsOnboarding] = useState(false);
138
139
  const [showOnboarding, setShowOnboarding] = useState(false);
140
+ // E4: when EnterpriseBadge fires forge:open-onboarding after a fresh
141
+ // key add, the source id rides along so the wizard scopes to that
142
+ // new tenant immediately instead of resolving the priority chain.
143
+ const [onboardingSourceId, setOnboardingSourceId] = useState<string | null>(null);
144
+ useEffect(() => {
145
+ const handler = (e: Event) => {
146
+ const detail = (e as CustomEvent).detail || {};
147
+ setOnboardingSourceId(detail.source_id || null);
148
+ setShowOnboarding(true);
149
+ };
150
+ window.addEventListener('forge:open-onboarding', handler);
151
+ return () => window.removeEventListener('forge:open-onboarding', handler);
152
+ }, []);
139
153
  useEffect(() => {
140
154
  fetch('/api/onboarding').then(r => r.json()).then(j => {
141
155
  const need = !!(j?.ok && !j.onboardingCompleted);
142
156
  setNeedsOnboarding(need);
143
- // Auto-open the drawer when setup is needed — saves the user a click.
144
- // They can still click "Skip" inside the drawer to dismiss.
157
+ // Auto-open the drawer when setup is needed — saves the user a
158
+ // click. When there's no enterprise yet, the wizard's first page
159
+ // is a splash asking "Add enterprise key, or continue with
160
+ // public?" so opt-out is one click away.
145
161
  if (need) setShowOnboarding(true);
146
162
  }).catch(() => {});
147
163
  }, []);
@@ -161,6 +177,7 @@ export default function Dashboard({ user }: { user: any }) {
161
177
  const [showUserMenu, setShowUserMenu] = useState(false);
162
178
  const [theme, setTheme] = useState<'dark' | 'light'>('dark');
163
179
  const [displayName, setDisplayName] = useState(user?.name || 'Forge');
180
+ const [profileDept, setProfileDept] = useState('');
164
181
  const terminalRef = useRef<WebTerminalHandle>(null);
165
182
 
166
183
  // Theme: load from localStorage + apply
@@ -182,7 +199,10 @@ export default function Dashboard({ user }: { user: any }) {
182
199
  // Fetch display name from settings
183
200
  const refreshDisplayName = useCallback(() => {
184
201
  fetch('/api/settings').then(r => r.json())
185
- .then((s: any) => { if (s.displayName) setDisplayName(s.displayName); })
202
+ .then((s: any) => {
203
+ if (s.displayName) setDisplayName(s.displayName);
204
+ if (typeof s.dept === 'string') setProfileDept(s.dept);
205
+ })
186
206
  .catch(() => {});
187
207
  }, []);
188
208
  useEffect(() => { refreshDisplayName(); }, [refreshDisplayName]);
@@ -228,17 +248,24 @@ export default function Dashboard({ user }: { user: any }) {
228
248
  return () => clearInterval(id);
229
249
  }, []);
230
250
 
231
- // Login status badge — load cached results on mount so the user
232
- // dropdown shows the red dot count without forcing a check.
251
+ // Login status badge — poll cached results on a 10s interval so the
252
+ // red-dot count picks up changes from the Test button / wizard apply /
253
+ // Reinstall without forcing the user to open + close the full panel.
254
+ // GET is cheap (reads JSON file, no probe), so this is fine.
233
255
  useEffect(() => {
234
- fetch('/api/login-status')
235
- .then((r) => r.json())
236
- .then((j) => {
237
- const rows = (j.rows || []) as Array<{ result: { ok: boolean } | null }>;
238
- const broken = rows.filter((r) => r.result && !r.result.ok).length;
239
- setLoginBadge({ broken, total: rows.length });
240
- })
241
- .catch(() => {});
256
+ const refresh = () => {
257
+ fetch('/api/login-status')
258
+ .then((r) => r.json())
259
+ .then((j) => {
260
+ const rows = (j.rows || []) as Array<{ result: { ok: boolean } | null }>;
261
+ const broken = rows.filter((r) => r.result && !r.result.ok).length;
262
+ setLoginBadge({ broken, total: rows.length });
263
+ })
264
+ .catch(() => {});
265
+ };
266
+ refresh();
267
+ const id = setInterval(refresh, 10_000);
268
+ return () => clearInterval(id);
242
269
  }, []);
243
270
 
244
271
  // Notifications: poll unread count at 30s, full fetch when panel opens
@@ -312,9 +339,11 @@ export default function Dashboard({ user }: { user: any }) {
312
339
  )}
313
340
  {showOnboarding && (
314
341
  <OnboardingDrawer
315
- onClose={() => setShowOnboarding(false)}
342
+ initialSourceId={onboardingSourceId}
343
+ onClose={() => { setShowOnboarding(false); setOnboardingSourceId(null); }}
316
344
  onComplete={() => {
317
345
  setShowOnboarding(false);
346
+ setOnboardingSourceId(null);
318
347
  setNeedsOnboarding(false);
319
348
  // Reload settings so chat picks up new API profile etc.
320
349
  fetchData();
@@ -353,9 +382,13 @@ export default function Dashboard({ user }: { user: any }) {
353
382
  <header className="h-12 border-b-2 border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-secondary)]">
354
383
  <div className="flex items-center gap-4">
355
384
  <img src="/icon.png" alt="Forge" width={28} height={28} className="rounded" />
356
- <span className="text-sm font-bold text-[var(--accent)]">
357
- Forge{displayName && displayName !== 'Forge' ? ` · ${displayName}` : ''}
358
- </span>
385
+ <span className="text-sm font-bold text-[var(--accent)]">Forge</span>
386
+ {/* Enterprise sources popover with per-tenant Re-sync / Reinstall /
387
+ Remove rows + inline Add tenant. Sits where the displayName used
388
+ to be so it's the first signal you see on cold open. */}
389
+ <Suspense fallback={null}>
390
+ <EnterpriseBadge onOpenSettings={() => setShowSettings(true)} />
391
+ </Suspense>
359
392
  {versionInfo && (
360
393
  <span className="flex items-center gap-1.5">
361
394
  <span className="text-[10px] text-[var(--text-secondary)]">v{versionInfo.current}</span>
@@ -638,6 +671,11 @@ export default function Dashboard({ user }: { user: any }) {
638
671
  onClick={() => { setShowUserMenu(v => !v); setShowNotifications(false); }}
639
672
  className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
640
673
  >
674
+ {profileDept && (
675
+ <span className="text-[9px] px-1 py-[1px] rounded bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 mr-1" title="Active department">
676
+ {profileDept}
677
+ </span>
678
+ )}
641
679
  {displayName} <span className="text-[8px]">▾</span>
642
680
  </button>
643
681
  {showUserMenu && (
@@ -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);