@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.
- package/CLAUDE.md +1 -1
- package/RELEASE_NOTES.md +83 -6
- package/app/api/bridge-info/route.ts +34 -0
- package/app/api/connectors/[id]/test/route.ts +14 -0
- package/app/api/connectors/import-config-template/route.ts +103 -13
- package/app/api/enterprise-keys/route.ts +204 -0
- package/app/api/marketplace/sync-all/route.ts +28 -0
- package/app/api/monitor/route.ts +29 -6
- package/app/api/onboarding/route.ts +897 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- package/bin/forge-server.mjs +189 -30
- package/cli/mw.mjs +16 -6
- package/cli/mw.ts +19 -6
- package/components/ConnectorsPanel.tsx +85 -13
- package/components/CraftTerminal.tsx +12 -3
- package/components/Dashboard.tsx +55 -17
- package/components/DocTerminal.tsx +12 -6
- package/components/EnterpriseBadge.tsx +420 -0
- package/components/LoginStatusPanel.tsx +15 -1
- package/components/OnboardingWizard.tsx +418 -31
- package/components/SettingsModal.tsx +382 -63
- package/components/SkillsPanel.tsx +116 -91
- package/components/WebTerminal.tsx +36 -13
- package/dev-test.sh +34 -1
- package/install.sh +29 -2
- package/lib/agents/claude-adapter.ts +18 -4
- package/lib/agents/index.ts +33 -4
- package/lib/auth/login-status.ts +14 -0
- package/lib/chat/agent-loop.ts +23 -1
- package/lib/chat/protocols/http.ts +15 -2
- package/lib/chat/tool-dispatcher.ts +163 -1
- package/lib/connectors/registry.ts +69 -4
- package/lib/connectors/sync.ts +536 -138
- package/lib/connectors/test-runner.ts +21 -3
- package/lib/connectors/types.ts +36 -4
- package/lib/connectors/wizard-template.ts +161 -0
- package/lib/dirs.ts +5 -0
- package/lib/enterprise-known.ts +34 -0
- package/lib/enterprise-secret.ts +87 -0
- package/lib/enterprise.ts +208 -0
- package/lib/help-docs/00-overview.md +12 -0
- package/lib/help-docs/01-settings.md +47 -1
- package/lib/help-docs/17-connectors.md +25 -22
- package/lib/help-docs/CLAUDE.md +1 -0
- package/lib/init.ts +13 -6
- package/lib/marketplace-sync.ts +70 -0
- package/lib/memory/temper-provision.ts +92 -0
- package/lib/pipeline-gc.ts +5 -2
- package/lib/pipeline.ts +26 -21
- package/lib/plugins/templates.ts +76 -3
- package/lib/projects.ts +85 -0
- package/lib/settings.ts +10 -0
- package/lib/telegram-bot.ts +14 -2
- package/lib/workflow-marketplace.ts +174 -108
- package/package.json +1 -1
- package/{middleware.ts → proxy.ts} +2 -1
- package/src/core/db/database.ts +8 -2
- package/templates/connector-config-template.json +0 -7
package/components/Dashboard.tsx
CHANGED
|
@@ -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
|
|
144
|
-
//
|
|
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) => {
|
|
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 —
|
|
232
|
-
//
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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);
|