@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.
@@ -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
- const onFocus = () => { load(); };
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..."