@aion0/forge 0.10.45 → 0.10.47
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/RELEASE_NOTES.md +5 -7
- package/app/api/auth/gitlab-2fa/route.ts +98 -0
- package/app/api/auth/idp-credentials/route.ts +41 -0
- package/app/api/auth/idp-login/route.ts +22 -0
- package/app/api/scratch/[...path]/route.ts +78 -0
- package/bin/forge-server.mjs +30 -3
- package/components/Dashboard.tsx +68 -70
- package/components/EnterpriseBadge.tsx +390 -1
- package/install.sh +6 -0
- package/lib/auth/gitlab-2fa.ts +443 -0
- package/lib/auth/idp-login.ts +221 -0
- package/lib/auth/terminal-keystroke.ts +245 -0
- package/lib/chat/link-patterns.ts +11 -0
- package/lib/chat/session-store.ts +5 -2
- package/lib/chat/tool-dispatcher.ts +90 -18
- package/lib/connectors/migration.ts +55 -0
- package/lib/connectors/registry.ts +20 -2
- package/lib/crypto.ts +1 -1
- package/lib/init.ts +9 -1
- package/lib/scratch-cleanup.ts +64 -0
- package/lib/settings.ts +20 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
|
@@ -42,6 +42,73 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
42
42
|
// pencil button doesn't collide with the bottom + Add tenant input.
|
|
43
43
|
const [editTenant, setEditTenant] = useState<string | null>(null);
|
|
44
44
|
const [editKey, setEditKey] = useState('');
|
|
45
|
+
// Phase 2: web SSO sweep. Reuses /api/login-status — same probe path
|
|
46
|
+
// as the LoginStatusPanel, just inlined here so users have ONE place
|
|
47
|
+
// for unified-auth actions.
|
|
48
|
+
const [webRows, setWebRows] = useState<Array<{
|
|
49
|
+
id: string;
|
|
50
|
+
label: string;
|
|
51
|
+
refresh: { kind: string; url?: string };
|
|
52
|
+
ok: boolean | null; // null = never checked
|
|
53
|
+
message: string;
|
|
54
|
+
checked_at: number | null;
|
|
55
|
+
}>>([]);
|
|
56
|
+
const [webBusy, setWebBusy] = useState(false);
|
|
57
|
+
const [webErr, setWebErr] = useState('');
|
|
58
|
+
// Phase 3: Unified IdP login — one user/pass/OTP entry → extension
|
|
59
|
+
// drives the SAML IdP form, all SPs piggy-back the resulting cookie.
|
|
60
|
+
const [idpUser, setIdpUser] = useState('');
|
|
61
|
+
// Prefill: saved credentials win; else fall back to email local-part.
|
|
62
|
+
// The saved-creds endpoint returns the plaintext password (AES-decrypted
|
|
63
|
+
// on read) so the user only needs to type OTP.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (idpUser || idpPass) return;
|
|
66
|
+
(async () => {
|
|
67
|
+
try {
|
|
68
|
+
const r = await fetch('/api/auth/idp-credentials');
|
|
69
|
+
if (r.ok) {
|
|
70
|
+
const c = await r.json();
|
|
71
|
+
if (c?.username && c?.password) {
|
|
72
|
+
setIdpUser(c.username);
|
|
73
|
+
setIdpPass(c.password);
|
|
74
|
+
setIdpSaveCreds(true);
|
|
75
|
+
setIdpHasSaved(true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch { /* fall through */ }
|
|
80
|
+
try {
|
|
81
|
+
const sr = await fetch('/api/settings');
|
|
82
|
+
if (sr.ok) {
|
|
83
|
+
const s = await sr.json();
|
|
84
|
+
const email = (s?.displayEmail || '').trim();
|
|
85
|
+
if (email) {
|
|
86
|
+
const local = email.includes('@') ? email.split('@')[0] : email;
|
|
87
|
+
if (local) setIdpUser(local);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch { /* ignore */ }
|
|
91
|
+
})();
|
|
92
|
+
}, []);
|
|
93
|
+
const [idpPass, setIdpPass] = useState('');
|
|
94
|
+
const [idpPassVisible, setIdpPassVisible] = useState(false);
|
|
95
|
+
// "Save credentials" checkbox state. When true on successful login,
|
|
96
|
+
// username + password are POSTed to /api/auth/idp-credentials (the
|
|
97
|
+
// backend AES-encrypts the password). On mount, we load any saved
|
|
98
|
+
// creds and prefill so the user only types OTP.
|
|
99
|
+
const [idpSaveCreds, setIdpSaveCreds] = useState(false);
|
|
100
|
+
const [idpHasSaved, setIdpHasSaved] = useState(false);
|
|
101
|
+
const [idpOtp, setIdpOtp] = useState('');
|
|
102
|
+
const [idpBusy, setIdpBusy] = useState(false);
|
|
103
|
+
const [idpErr, setIdpErr] = useState('');
|
|
104
|
+
const [idpStatus, setIdpStatus] = useState('');
|
|
105
|
+
// Terminal commands that failed to auto-run — surfaced so the user
|
|
106
|
+
// can paste them manually (Accessibility denied, no Terminal open,
|
|
107
|
+
// etc.). Cleared on every new login attempt.
|
|
108
|
+
const [idpManualCommands, setIdpManualCommands] = useState<Array<{ name: string; command: string; error?: string }>>([]);
|
|
109
|
+
// IdP entries that failed (different credentials required, etc.) —
|
|
110
|
+
// surface the trigger URL so the user can complete login by hand.
|
|
111
|
+
const [idpManualUrls, setIdpManualUrls] = useState<Array<{ host: string; url: string; error?: string }>>([]);
|
|
45
112
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
46
113
|
|
|
47
114
|
const load = async () => {
|
|
@@ -55,13 +122,161 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
55
122
|
}
|
|
56
123
|
};
|
|
57
124
|
|
|
125
|
+
type LoginRow = {
|
|
126
|
+
source: { id: string; label: string; category: string; refresh: { kind: string; url?: string } };
|
|
127
|
+
result: { ok: boolean; message?: string; checked_at: number } | null;
|
|
128
|
+
};
|
|
129
|
+
const ingestLoginRows = (rows: LoginRow[]) => {
|
|
130
|
+
setWebRows(
|
|
131
|
+
rows
|
|
132
|
+
.filter(r => r.source.category === 'browser')
|
|
133
|
+
.map(r => ({
|
|
134
|
+
id: r.source.id,
|
|
135
|
+
label: r.source.label,
|
|
136
|
+
refresh: r.source.refresh,
|
|
137
|
+
ok: r.result ? r.result.ok : null,
|
|
138
|
+
message: r.result?.message || '',
|
|
139
|
+
checked_at: r.result?.checked_at || null,
|
|
140
|
+
})),
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const loadWebSessions = async () => {
|
|
145
|
+
try {
|
|
146
|
+
const r = await fetch('/api/login-status');
|
|
147
|
+
if (!r.ok) return;
|
|
148
|
+
const data = await r.json();
|
|
149
|
+
ingestLoginRows((data?.rows || []) as LoginRow[]);
|
|
150
|
+
} catch { /* leave previous */ }
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const handleCheckAllWeb = async () => {
|
|
154
|
+
if (webBusy) return;
|
|
155
|
+
setWebBusy(true); setWebErr('');
|
|
156
|
+
try {
|
|
157
|
+
const r = await fetch('/api/login-status', { method: 'POST' });
|
|
158
|
+
const data = await r.json();
|
|
159
|
+
if (!r.ok || data?.error) {
|
|
160
|
+
setWebErr(data?.error || `HTTP ${r.status}`);
|
|
161
|
+
} else {
|
|
162
|
+
const rows = (data?.rows || []) as LoginRow[];
|
|
163
|
+
ingestLoginRows(rows);
|
|
164
|
+
// Auto-open the failed browser sources — that's the whole point
|
|
165
|
+
// of this section. Pops up a tab per failed site so the user can
|
|
166
|
+
// complete IdP login. Done sequentially with a tiny delay so the
|
|
167
|
+
// popup blocker treats the first one as user-initiated.
|
|
168
|
+
const failed = rows.filter(r =>
|
|
169
|
+
r.source.category === 'browser' &&
|
|
170
|
+
r.result && !r.result.ok &&
|
|
171
|
+
r.source.refresh.kind === 'open-url' &&
|
|
172
|
+
r.source.refresh.url,
|
|
173
|
+
);
|
|
174
|
+
failed.forEach((r, i) => setTimeout(() => {
|
|
175
|
+
window.open(r.source.refresh.url!, '_blank', 'noopener');
|
|
176
|
+
}, i * 250));
|
|
177
|
+
}
|
|
178
|
+
} catch (e) {
|
|
179
|
+
setWebErr(e instanceof Error ? e.message : String(e));
|
|
180
|
+
} finally {
|
|
181
|
+
setWebBusy(false);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handleOpenWeb = (row: { refresh: { kind: string; url?: string } }) => {
|
|
186
|
+
if (row.refresh.kind === 'open-url' && row.refresh.url) {
|
|
187
|
+
window.open(row.refresh.url, '_blank', 'noopener');
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleIdpLogin = async () => {
|
|
192
|
+
if (idpBusy) return;
|
|
193
|
+
if (!idpUser.trim() || !idpPass || !idpOtp.trim()) {
|
|
194
|
+
setIdpErr('Username, password, and OTP are all required');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
setIdpBusy(true); setIdpErr(''); setIdpStatus(''); setIdpManualCommands([]); setIdpManualUrls([]);
|
|
198
|
+
try {
|
|
199
|
+
const r = await fetch('/api/auth/idp-login', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
body: JSON.stringify({ username: idpUser.trim(), password: idpPass, otp: idpOtp.trim() }),
|
|
203
|
+
});
|
|
204
|
+
const data = await r.json();
|
|
205
|
+
// Template may declare multiple IdPs (e.g. FAC for mantis/pmdb/tp +
|
|
206
|
+
// a regional SAML for scap). data.idp_logins holds per-IdP results.
|
|
207
|
+
// Render per-entry status so partial failures are visible.
|
|
208
|
+
const logins = Array.isArray(data.idp_logins) ? data.idp_logins : [];
|
|
209
|
+
const parts: string[] = [];
|
|
210
|
+
const failedTerminals: Array<{ name: string; command: string; error?: string }> = [];
|
|
211
|
+
const failedIdpUrls: Array<{ host: string; url: string; error?: string }> = [];
|
|
212
|
+
for (const entry of logins as Array<{
|
|
213
|
+
idp_host: string;
|
|
214
|
+
trigger_url?: string;
|
|
215
|
+
ok: boolean;
|
|
216
|
+
steps_completed?: number;
|
|
217
|
+
error?: string;
|
|
218
|
+
post_login_terminal?: Array<{ name: string; ok: boolean; command?: string; error?: string }>;
|
|
219
|
+
}>) {
|
|
220
|
+
if (entry.ok) {
|
|
221
|
+
parts.push(`✓ ${entry.idp_host} (${entry.steps_completed || 0} steps)`);
|
|
222
|
+
} else {
|
|
223
|
+
parts.push(`✗ ${entry.idp_host}: ${entry.error || 'failed'}`);
|
|
224
|
+
if (entry.trigger_url) {
|
|
225
|
+
failedIdpUrls.push({ host: entry.idp_host, url: entry.trigger_url, error: entry.error });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (Array.isArray(entry.post_login_terminal)) {
|
|
229
|
+
for (const r of entry.post_login_terminal) {
|
|
230
|
+
parts.push(r.ok ? `${r.name} ✓` : `${r.name}: ${r.error || 'skipped'}`);
|
|
231
|
+
if (!r.ok && r.command) {
|
|
232
|
+
failedTerminals.push({ name: r.name, command: r.command, error: r.error });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
setIdpManualCommands(failedTerminals);
|
|
238
|
+
setIdpManualUrls(failedIdpUrls);
|
|
239
|
+
if (!data.ok) {
|
|
240
|
+
// Surface aggregate error but keep per-entry breakdown if we have one.
|
|
241
|
+
setIdpErr(parts.length > 0 ? parts.join(' · ') : (data.error || 'IdP login failed'));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// Persist creds AFTER a successful login so we never store a
|
|
245
|
+
// password the IdP just rejected. Encrypted at rest server-side.
|
|
246
|
+
if (idpSaveCreds && idpUser.trim() && idpPass) {
|
|
247
|
+
try {
|
|
248
|
+
await fetch('/api/auth/idp-credentials', {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: { 'Content-Type': 'application/json' },
|
|
251
|
+
body: JSON.stringify({ username: idpUser.trim(), password: idpPass }),
|
|
252
|
+
});
|
|
253
|
+
setIdpHasSaved(true);
|
|
254
|
+
} catch { /* non-fatal */ }
|
|
255
|
+
}
|
|
256
|
+
// Only wipe OTP after success — keep password for follow-up Login
|
|
257
|
+
// All Sites within the same session (OTP is single-use anyway).
|
|
258
|
+
setIdpOtp('');
|
|
259
|
+
if (!idpSaveCreds) setIdpPass('');
|
|
260
|
+
setIdpStatus(`${parts.join(' · ')}. Re-probing sessions…`);
|
|
261
|
+
await handleCheckAllWeb();
|
|
262
|
+
} catch (e) {
|
|
263
|
+
setIdpErr(e instanceof Error ? e.message : String(e));
|
|
264
|
+
} finally {
|
|
265
|
+
setIdpBusy(false);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
58
269
|
useEffect(() => {
|
|
59
270
|
load();
|
|
60
|
-
|
|
271
|
+
loadWebSessions();
|
|
272
|
+
const onFocus = () => { load(); loadWebSessions(); };
|
|
61
273
|
window.addEventListener('focus', onFocus);
|
|
62
274
|
return () => window.removeEventListener('focus', onFocus);
|
|
63
275
|
}, []);
|
|
64
276
|
|
|
277
|
+
// Refresh web rows when popover opens (so the dropdown is fresh).
|
|
278
|
+
useEffect(() => { if (open) { loadWebSessions(); } }, [open]);
|
|
279
|
+
|
|
65
280
|
// Close popover on outside click.
|
|
66
281
|
useEffect(() => {
|
|
67
282
|
if (!open) return;
|
|
@@ -225,6 +440,180 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
225
440
|
</button>
|
|
226
441
|
{open && (
|
|
227
442
|
<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">
|
|
443
|
+
{/* Phase 3 — Unified IdP login. One user/pass/OTP entry →
|
|
444
|
+
extension drives the SAML IdP form → every SAML SP gets
|
|
445
|
+
its cookie via the resulting session. Only shown when at
|
|
446
|
+
least one web connector exists (no IdP, no point). */}
|
|
447
|
+
{webRows.length > 0 && (
|
|
448
|
+
<div className="border border-[var(--border)] rounded px-2 py-1.5 space-y-1.5">
|
|
449
|
+
<div className="flex items-center gap-1.5">
|
|
450
|
+
<span className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)]">
|
|
451
|
+
Unified login
|
|
452
|
+
</span>
|
|
453
|
+
<span className="text-[9px] text-[var(--text-secondary)] ml-auto">
|
|
454
|
+
one fill → all SAML sites
|
|
455
|
+
</span>
|
|
456
|
+
</div>
|
|
457
|
+
<div className="grid grid-cols-2 gap-1">
|
|
458
|
+
<input
|
|
459
|
+
type="text"
|
|
460
|
+
placeholder="Username"
|
|
461
|
+
autoComplete="username"
|
|
462
|
+
value={idpUser}
|
|
463
|
+
onChange={(e) => setIdpUser(e.target.value)}
|
|
464
|
+
className="text-xs px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded"
|
|
465
|
+
disabled={idpBusy}
|
|
466
|
+
/>
|
|
467
|
+
<div className="relative">
|
|
468
|
+
<input
|
|
469
|
+
type={idpPassVisible ? 'text' : 'password'}
|
|
470
|
+
placeholder="Password"
|
|
471
|
+
autoComplete="current-password"
|
|
472
|
+
value={idpPass}
|
|
473
|
+
onChange={(e) => setIdpPass(e.target.value)}
|
|
474
|
+
className="text-xs px-2 py-1 pr-7 bg-[var(--bg)] border border-[var(--border)] rounded w-full"
|
|
475
|
+
disabled={idpBusy}
|
|
476
|
+
/>
|
|
477
|
+
<button
|
|
478
|
+
type="button"
|
|
479
|
+
onClick={() => setIdpPassVisible((v) => !v)}
|
|
480
|
+
tabIndex={-1}
|
|
481
|
+
title={idpPassVisible ? 'Hide password' : 'Show password'}
|
|
482
|
+
className="absolute right-1 top-1/2 -translate-y-1/2 px-1 text-[var(--text-secondary)] hover:text-[var(--text)] text-[11px] leading-none"
|
|
483
|
+
>
|
|
484
|
+
{idpPassVisible ? '🙈' : '👁'}
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
<div className="flex gap-1">
|
|
489
|
+
<input
|
|
490
|
+
type="text"
|
|
491
|
+
inputMode="numeric"
|
|
492
|
+
pattern="\d*"
|
|
493
|
+
autoComplete="one-time-code"
|
|
494
|
+
maxLength={8}
|
|
495
|
+
placeholder="6-digit OTP"
|
|
496
|
+
value={idpOtp}
|
|
497
|
+
onChange={(e) => setIdpOtp(e.target.value.replace(/\D/g, ''))}
|
|
498
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleIdpLogin(); }}
|
|
499
|
+
className="flex-1 text-xs px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded font-mono tracking-wider"
|
|
500
|
+
disabled={idpBusy}
|
|
501
|
+
/>
|
|
502
|
+
<button
|
|
503
|
+
onClick={handleIdpLogin}
|
|
504
|
+
disabled={idpBusy || !idpUser.trim() || !idpPass || !idpOtp.trim()}
|
|
505
|
+
className="text-[10px] px-2 py-1 rounded bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
506
|
+
>
|
|
507
|
+
{idpBusy ? '…' : 'Login all sites'}
|
|
508
|
+
</button>
|
|
509
|
+
</div>
|
|
510
|
+
<div className="flex items-center gap-2 text-[10px] text-[var(--text-secondary)]">
|
|
511
|
+
<label className="flex items-center gap-1 cursor-pointer select-none">
|
|
512
|
+
<input
|
|
513
|
+
type="checkbox"
|
|
514
|
+
checked={idpSaveCreds}
|
|
515
|
+
onChange={(e) => setIdpSaveCreds(e.target.checked)}
|
|
516
|
+
className="accent-amber-500"
|
|
517
|
+
/>
|
|
518
|
+
<span>Save user + password (AES-encrypted)</span>
|
|
519
|
+
</label>
|
|
520
|
+
{idpHasSaved && (
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
onClick={async () => {
|
|
524
|
+
try { await fetch('/api/auth/idp-credentials', { method: 'DELETE' }); } catch {}
|
|
525
|
+
setIdpUser(''); setIdpPass(''); setIdpHasSaved(false); setIdpSaveCreds(false);
|
|
526
|
+
}}
|
|
527
|
+
className="ml-auto text-red-400 hover:text-red-300 underline decoration-dotted"
|
|
528
|
+
title="Forget saved username + password"
|
|
529
|
+
>
|
|
530
|
+
Clear saved
|
|
531
|
+
</button>
|
|
532
|
+
)}
|
|
533
|
+
</div>
|
|
534
|
+
{idpErr && <div className="text-[10px] text-red-400">{idpErr}</div>}
|
|
535
|
+
{idpStatus && <div className="text-[10px] text-emerald-400">{idpStatus}</div>}
|
|
536
|
+
{idpManualUrls.length > 0 && (
|
|
537
|
+
<div className="text-[10px] text-amber-400 space-y-1 mt-1">
|
|
538
|
+
<div>Some IdPs require different credentials — open and log in manually:</div>
|
|
539
|
+
{idpManualUrls.map((u, i) => (
|
|
540
|
+
<div key={i} className="flex items-center gap-1">
|
|
541
|
+
<a
|
|
542
|
+
href={u.url}
|
|
543
|
+
target="_blank"
|
|
544
|
+
rel="noopener"
|
|
545
|
+
title={u.error || u.host}
|
|
546
|
+
className="flex-1 bg-black/30 px-1.5 py-0.5 rounded font-mono text-[10px] break-all hover:bg-black/40 underline"
|
|
547
|
+
>
|
|
548
|
+
{u.url}
|
|
549
|
+
</a>
|
|
550
|
+
</div>
|
|
551
|
+
))}
|
|
552
|
+
</div>
|
|
553
|
+
)}
|
|
554
|
+
{idpManualCommands.length > 0 && (
|
|
555
|
+
<div className="text-[10px] text-amber-400 space-y-1 mt-1">
|
|
556
|
+
<div>Auto-run failed — paste these in your Terminal:</div>
|
|
557
|
+
{idpManualCommands.map((c, i) => (
|
|
558
|
+
<div key={i} className="flex items-center gap-1">
|
|
559
|
+
<code
|
|
560
|
+
className="flex-1 bg-black/30 px-1.5 py-0.5 rounded font-mono text-[10px] break-all cursor-pointer hover:bg-black/40"
|
|
561
|
+
title={c.error ? `Reason: ${c.error}` : 'Click to copy'}
|
|
562
|
+
onClick={() => { navigator.clipboard.writeText(c.command).catch(() => {}); }}
|
|
563
|
+
>
|
|
564
|
+
{c.command}
|
|
565
|
+
</code>
|
|
566
|
+
</div>
|
|
567
|
+
))}
|
|
568
|
+
</div>
|
|
569
|
+
)}
|
|
570
|
+
</div>
|
|
571
|
+
)}
|
|
572
|
+
|
|
573
|
+
{/* Phase 2 — Web SSO sweep. Same probe path as LoginStatusPanel,
|
|
574
|
+
inlined here so users have ONE dropdown for unified auth. */}
|
|
575
|
+
{webRows.length > 0 && (
|
|
576
|
+
<div className="border border-[var(--border)] rounded px-2 py-1.5 space-y-1.5">
|
|
577
|
+
<div className="flex items-center gap-1.5">
|
|
578
|
+
<span className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)]">
|
|
579
|
+
Web sessions ({webRows.length})
|
|
580
|
+
</span>
|
|
581
|
+
<button
|
|
582
|
+
onClick={handleCheckAllWeb}
|
|
583
|
+
disabled={webBusy}
|
|
584
|
+
className="ml-auto text-[10px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 disabled:opacity-40"
|
|
585
|
+
title="Probe each site via the extension. Opens login pages for any that fail."
|
|
586
|
+
>
|
|
587
|
+
{webBusy ? '…' : 'Check & open failed'}
|
|
588
|
+
</button>
|
|
589
|
+
</div>
|
|
590
|
+
<div className="space-y-1">
|
|
591
|
+
{webRows.map(row => {
|
|
592
|
+
const dot = row.ok === null ? '⚪' : row.ok ? '🟢' : '🔴';
|
|
593
|
+
const canOpen = row.refresh.kind === 'open-url' && !!row.refresh.url;
|
|
594
|
+
return (
|
|
595
|
+
<div key={row.id} className="flex items-center gap-1.5 text-[10px]">
|
|
596
|
+
<span>{dot}</span>
|
|
597
|
+
<span className="text-[var(--text-primary)] truncate flex-1" title={row.message}>
|
|
598
|
+
{row.label}
|
|
599
|
+
</span>
|
|
600
|
+
{canOpen && (
|
|
601
|
+
<button
|
|
602
|
+
onClick={() => handleOpenWeb(row)}
|
|
603
|
+
className="text-[9px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
604
|
+
title={`Open ${row.refresh.url}`}
|
|
605
|
+
>
|
|
606
|
+
Open
|
|
607
|
+
</button>
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
})}
|
|
612
|
+
</div>
|
|
613
|
+
{webErr && <div className="text-[10px] text-red-400">{webErr}</div>}
|
|
614
|
+
</div>
|
|
615
|
+
)}
|
|
616
|
+
|
|
228
617
|
<div className="text-[10px] text-[var(--text-secondary)] uppercase tracking-wider px-1">
|
|
229
618
|
Enterprise tenants ({sources.length})
|
|
230
619
|
</div>
|
package/install.sh
CHANGED
|
@@ -100,6 +100,12 @@ if [ "$1" = "local" ] || [ "$1" = "--local" ]; then
|
|
|
100
100
|
# behaviour matches what a real user gets from `forge upgrade`.
|
|
101
101
|
echo "[forge] Building from local source (npm pack flow)..."
|
|
102
102
|
SRC_DIR="$(pwd)"
|
|
103
|
+
# Wipe Turbopack's incremental cache + previous .next bundle.
|
|
104
|
+
# Without this, Next.js 16 sometimes reuses stale chunks for files
|
|
105
|
+
# whose source hash didn't shift enough between builds (observed
|
|
106
|
+
# silently shipping old tool-dispatcher logic). Cheap insurance.
|
|
107
|
+
echo "[forge] Clearing .next + Turbopack cache for a clean build..."
|
|
108
|
+
rm -rf "$SRC_DIR/.next" "$SRC_DIR/node_modules/.cache" 2>/dev/null || true
|
|
103
109
|
echo "[forge] Running pnpm build..."
|
|
104
110
|
pnpm build || echo "[forge] Build completed with warnings (non-critical)"
|
|
105
111
|
echo "[forge] Packing tarball..."
|