@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
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.47
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-08
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
6
|
-
|
|
7
|
-
### Bug Fixes
|
|
8
|
-
- fix: validate enterprise key against GitHub before persisting
|
|
5
|
+
## Changes since v0.10.46
|
|
9
6
|
|
|
10
7
|
### Other
|
|
11
|
-
-
|
|
8
|
+
- ui(header): selected tab uses accent tint, not bg-secondary shade
|
|
9
|
+
- ui(header): bell for Alerts, stacked dept/name pill, +1px nav font
|
|
12
10
|
|
|
13
11
|
|
|
14
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
12
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.46...v0.10.47
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab SSH 2FA verify API.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/auth/gitlab-2fa
|
|
5
|
+
* → { host, verifiedAt: string | null, seconds_left: number }
|
|
6
|
+
*
|
|
7
|
+
* POST /api/auth/gitlab-2fa
|
|
8
|
+
* body: { otp: "123456" }
|
|
9
|
+
* → { ok: true, verifiedAt, seconds_left, host } |
|
|
10
|
+
* { ok: false, error, transcript? }
|
|
11
|
+
*
|
|
12
|
+
* verifiedAt is persisted in settings.gitlab2fa so dropdown survives a
|
|
13
|
+
* page refresh.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { NextResponse } from 'next/server';
|
|
17
|
+
import { loadSettings, saveSettings } from '@/lib/settings';
|
|
18
|
+
import {
|
|
19
|
+
verifyGitlab2FA,
|
|
20
|
+
verifyGitlab2FAViaKeystroke,
|
|
21
|
+
getGitlabSshHost,
|
|
22
|
+
secondsLeft,
|
|
23
|
+
GITLAB_2FA_VALID_MS,
|
|
24
|
+
} from '@/lib/auth/gitlab-2fa';
|
|
25
|
+
|
|
26
|
+
export async function GET() {
|
|
27
|
+
const settings = loadSettings();
|
|
28
|
+
const host = getGitlabSshHost();
|
|
29
|
+
const recorded = settings.gitlab2fa;
|
|
30
|
+
// Drop the stamp if host changed (user pointed gitlab elsewhere)
|
|
31
|
+
const valid = recorded && (!host || recorded.host === host);
|
|
32
|
+
const verifiedAt = valid ? recorded?.verifiedAt ?? null : null;
|
|
33
|
+
return NextResponse.json({
|
|
34
|
+
host,
|
|
35
|
+
verifiedAt,
|
|
36
|
+
seconds_left: secondsLeft(verifiedAt ?? undefined),
|
|
37
|
+
validity_seconds: Math.floor(GITLAB_2FA_VALID_MS / 1000),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function POST(req: Request) {
|
|
42
|
+
let body: any = {};
|
|
43
|
+
try { body = await req.json(); } catch {}
|
|
44
|
+
|
|
45
|
+
// Manual mode: caller already ran `ssh git@host 2fa_verify` in their
|
|
46
|
+
// own terminal (necessary when corp VPN like FortiClient does per-
|
|
47
|
+
// process tunnel filtering — node-spawned ssh can't reach the host).
|
|
48
|
+
// Forge just stamps verifiedAt so the 55-minute clock starts.
|
|
49
|
+
if (body?.manual === true) {
|
|
50
|
+
const host = getGitlabSshHost();
|
|
51
|
+
if (!host) {
|
|
52
|
+
return NextResponse.json({ ok: false, error: 'GitLab connector not configured' }, { status: 400 });
|
|
53
|
+
}
|
|
54
|
+
const verifiedAt = new Date().toISOString();
|
|
55
|
+
const settings = loadSettings();
|
|
56
|
+
settings.gitlab2fa = { verifiedAt, host };
|
|
57
|
+
saveSettings(settings);
|
|
58
|
+
return NextResponse.json({
|
|
59
|
+
ok: true,
|
|
60
|
+
verifiedAt,
|
|
61
|
+
host,
|
|
62
|
+
seconds_left: Math.floor(GITLAB_2FA_VALID_MS / 1000),
|
|
63
|
+
mode: 'manual',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const otp = String(body?.otp || '').trim();
|
|
68
|
+
if (!otp) {
|
|
69
|
+
return NextResponse.json({ ok: false, error: 'otp is required' }, { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Mode selector: 'keystroke' uses osascript+System Events to type
|
|
73
|
+
// into the user's frontmost Terminal — required in environments
|
|
74
|
+
// where the corp VPN blocks Forge-spawned ssh (FortiClient).
|
|
75
|
+
// Default is the direct node-pty path (works for non-filtered envs).
|
|
76
|
+
const mode = String(body?.mode || 'direct');
|
|
77
|
+
const r = mode === 'keystroke'
|
|
78
|
+
? await verifyGitlab2FAViaKeystroke(otp)
|
|
79
|
+
: await verifyGitlab2FA(otp);
|
|
80
|
+
if (!r.ok) {
|
|
81
|
+
return NextResponse.json(
|
|
82
|
+
{ ok: false, error: r.error || 'verify failed', transcript: r.transcript },
|
|
83
|
+
{ status: 400 },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Persist for the dropdown's "X min left" rendering.
|
|
88
|
+
const settings = loadSettings();
|
|
89
|
+
settings.gitlab2fa = { verifiedAt: r.verifiedAt!, host: r.host! };
|
|
90
|
+
saveSettings(settings);
|
|
91
|
+
|
|
92
|
+
return NextResponse.json({
|
|
93
|
+
ok: true,
|
|
94
|
+
verifiedAt: r.verifiedAt,
|
|
95
|
+
host: r.host,
|
|
96
|
+
seconds_left: Math.floor(GITLAB_2FA_VALID_MS / 1000),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/auth/idp-credentials → { username, password } (decrypted)
|
|
3
|
+
* POST /api/auth/idp-credentials body: { username, password }
|
|
4
|
+
* DELETE /api/auth/idp-credentials
|
|
5
|
+
*
|
|
6
|
+
* Read/write the EnterpriseBadge's saved unified-login credentials.
|
|
7
|
+
* Password lives in SECRET_FIELDS so it's AES-256-GCM encrypted at rest;
|
|
8
|
+
* this endpoint hands it back in plaintext for the local UI to prefill
|
|
9
|
+
* (Forge is single-user, runs on localhost — equivalent threat surface
|
|
10
|
+
* to the user's own keychain). OTP is never persisted — it's TOTP.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NextResponse } from 'next/server';
|
|
14
|
+
import { loadSettings, saveSettings } from '@/lib/settings';
|
|
15
|
+
|
|
16
|
+
export async function GET() {
|
|
17
|
+
const s = loadSettings() as unknown as Record<string, unknown>;
|
|
18
|
+
return NextResponse.json({
|
|
19
|
+
username: (s.idpSavedUsername as string) || '',
|
|
20
|
+
password: (s.idpSavedPassword as string) || '',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function POST(req: Request) {
|
|
25
|
+
let body: { username?: string; password?: string } = {};
|
|
26
|
+
try { body = await req.json(); } catch { /* empty body */ }
|
|
27
|
+
const username = String(body.username || '').trim();
|
|
28
|
+
const password = String(body.password || '');
|
|
29
|
+
if (!username || !password) {
|
|
30
|
+
return NextResponse.json({ ok: false, error: 'username and password are required' }, { status: 400 });
|
|
31
|
+
}
|
|
32
|
+
const current = loadSettings() as unknown as Record<string, unknown>;
|
|
33
|
+
saveSettings({ ...current, idpSavedUsername: username, idpSavedPassword: password } as never);
|
|
34
|
+
return NextResponse.json({ ok: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function DELETE() {
|
|
38
|
+
const current = loadSettings() as unknown as Record<string, unknown>;
|
|
39
|
+
saveSettings({ ...current, idpSavedUsername: '', idpSavedPassword: '' } as never);
|
|
40
|
+
return NextResponse.json({ ok: true });
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/auth/idp-login
|
|
3
|
+
* body: { username, password, otp }
|
|
4
|
+
* → { ok, idp_logins: [{idp_host, ok, steps_completed?, final_url?, error?, post_login_terminal?}], error? }
|
|
5
|
+
*
|
|
6
|
+
* Drives one or more SAML IdP logins (template `_idp` may be array) via
|
|
7
|
+
* the extension. Secrets stay in this request — never persisted.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { NextResponse } from 'next/server';
|
|
11
|
+
import { performIdpLogin } from '@/lib/auth/idp-login';
|
|
12
|
+
|
|
13
|
+
export async function POST(req: Request) {
|
|
14
|
+
let body: any = {};
|
|
15
|
+
try { body = await req.json(); } catch {}
|
|
16
|
+
const r = await performIdpLogin({
|
|
17
|
+
username: String(body?.username || '').trim(),
|
|
18
|
+
password: String(body?.password || ''),
|
|
19
|
+
otp: String(body?.otp || '').trim(),
|
|
20
|
+
});
|
|
21
|
+
return NextResponse.json(r, { status: r.ok ? 200 : 400 });
|
|
22
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/scratch/<path...>
|
|
3
|
+
* ?download=1 → force Content-Disposition: attachment
|
|
4
|
+
*
|
|
5
|
+
* Serves files from `<dataDir>/scratch/` so chat-emitted paths like
|
|
6
|
+
* `scratch/report-2026-06-08.md` can be clicked open from the chat UI.
|
|
7
|
+
* Path-traversal protected: any segment containing `..` or any resolved
|
|
8
|
+
* path escaping the scratch root is rejected.
|
|
9
|
+
*
|
|
10
|
+
* Auth-gated by Forge's proxy middleware just like every other /api/*.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NextResponse } from 'next/server';
|
|
14
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, resolve, extname } from 'node:path';
|
|
16
|
+
import { getDataDir } from '@/lib/dirs';
|
|
17
|
+
|
|
18
|
+
const MIME: Record<string, string> = {
|
|
19
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
20
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
21
|
+
'.json': 'application/json; charset=utf-8',
|
|
22
|
+
'.yaml': 'application/yaml; charset=utf-8',
|
|
23
|
+
'.yml': 'application/yaml; charset=utf-8',
|
|
24
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
25
|
+
'.log': 'text/plain; charset=utf-8',
|
|
26
|
+
'.html': 'text/html; charset=utf-8',
|
|
27
|
+
'.pdf': 'application/pdf',
|
|
28
|
+
'.png': 'image/png',
|
|
29
|
+
'.jpg': 'image/jpeg',
|
|
30
|
+
'.jpeg': 'image/jpeg',
|
|
31
|
+
'.gif': 'image/gif',
|
|
32
|
+
'.svg': 'image/svg+xml',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function GET(
|
|
36
|
+
req: Request,
|
|
37
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
38
|
+
) {
|
|
39
|
+
const { path: parts } = await ctx.params;
|
|
40
|
+
if (!parts || parts.length === 0) {
|
|
41
|
+
return NextResponse.json({ ok: false, error: 'missing path' }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
// Reject explicit traversal attempts before path construction.
|
|
44
|
+
for (const seg of parts) {
|
|
45
|
+
if (seg === '..' || seg.startsWith('..')) {
|
|
46
|
+
return NextResponse.json({ ok: false, error: 'path traversal rejected' }, { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const scratchRoot = resolve(join(getDataDir(), 'scratch'));
|
|
50
|
+
const target = resolve(join(scratchRoot, ...parts));
|
|
51
|
+
// Verify the resolved path stays under scratchRoot — handles symlink
|
|
52
|
+
// tricks the segment check above can't catch.
|
|
53
|
+
if (!target.startsWith(scratchRoot + '/') && target !== scratchRoot) {
|
|
54
|
+
return NextResponse.json({ ok: false, error: 'path traversal rejected' }, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
if (!existsSync(target)) {
|
|
57
|
+
return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
|
|
58
|
+
}
|
|
59
|
+
const st = statSync(target);
|
|
60
|
+
if (!st.isFile()) {
|
|
61
|
+
return NextResponse.json({ ok: false, error: 'not a file' }, { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
const url = new URL(req.url);
|
|
64
|
+
const download = url.searchParams.get('download') === '1';
|
|
65
|
+
const ext = extname(target).toLowerCase();
|
|
66
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
67
|
+
const data = readFileSync(target);
|
|
68
|
+
const filename = parts[parts.length - 1];
|
|
69
|
+
return new NextResponse(new Uint8Array(data), {
|
|
70
|
+
status: 200,
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': mime,
|
|
73
|
+
'Content-Length': String(st.size),
|
|
74
|
+
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${filename.replace(/"/g, '\\"')}"`,
|
|
75
|
+
'Cache-Control': 'private, max-age=0, must-revalidate',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -656,7 +656,16 @@ function startBackground() {
|
|
|
656
656
|
const child = spawn(nextBin, ['start', '-p', String(webPort)], {
|
|
657
657
|
cwd: ROOT,
|
|
658
658
|
stdio: ['ignore', logFd, logFd],
|
|
659
|
-
env: {
|
|
659
|
+
env: {
|
|
660
|
+
...process.env,
|
|
661
|
+
FORGE_EXTERNAL_SERVICES: '1',
|
|
662
|
+
// --use-system-ca lets Node 22+ trust the OS keychain in addition
|
|
663
|
+
// to its baked-in CA bundle. Corp networks (FortiClient SSL
|
|
664
|
+
// inspection, Zscaler, etc.) install a custom root via MDM; Node
|
|
665
|
+
// doesn't see it without this flag, so marketplace sync fails with
|
|
666
|
+
// 'self-signed certificate in certificate chain' on api.github.com.
|
|
667
|
+
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
|
|
668
|
+
},
|
|
660
669
|
detached: true,
|
|
661
670
|
});
|
|
662
671
|
|
|
@@ -738,7 +747,16 @@ if (isDev) {
|
|
|
738
747
|
const child = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(webPort)], {
|
|
739
748
|
cwd: ROOT,
|
|
740
749
|
stdio: 'inherit',
|
|
741
|
-
env: {
|
|
750
|
+
env: {
|
|
751
|
+
...process.env,
|
|
752
|
+
FORGE_EXTERNAL_SERVICES: '1',
|
|
753
|
+
// --use-system-ca lets Node 22+ trust the OS keychain in addition
|
|
754
|
+
// to its baked-in CA bundle. Corp networks (FortiClient SSL
|
|
755
|
+
// inspection, Zscaler, etc.) install a custom root via MDM; Node
|
|
756
|
+
// doesn't see it without this flag, so marketplace sync fails with
|
|
757
|
+
// 'self-signed certificate in certificate chain' on api.github.com.
|
|
758
|
+
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
|
|
759
|
+
},
|
|
742
760
|
});
|
|
743
761
|
child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
|
|
744
762
|
} else {
|
|
@@ -748,7 +766,16 @@ if (isDev) {
|
|
|
748
766
|
const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
|
|
749
767
|
cwd: ROOT,
|
|
750
768
|
stdio: 'inherit',
|
|
751
|
-
env: {
|
|
769
|
+
env: {
|
|
770
|
+
...process.env,
|
|
771
|
+
FORGE_EXTERNAL_SERVICES: '1',
|
|
772
|
+
// --use-system-ca lets Node 22+ trust the OS keychain in addition
|
|
773
|
+
// to its baked-in CA bundle. Corp networks (FortiClient SSL
|
|
774
|
+
// inspection, Zscaler, etc.) install a custom root via MDM; Node
|
|
775
|
+
// doesn't see it without this flag, so marketplace sync fails with
|
|
776
|
+
// 'self-signed certificate in certificate chain' on api.github.com.
|
|
777
|
+
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
|
|
778
|
+
},
|
|
752
779
|
});
|
|
753
780
|
child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
|
|
754
781
|
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -422,9 +422,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
422
422
|
<button
|
|
423
423
|
key={mode}
|
|
424
424
|
onClick={() => setViewMode(mode)}
|
|
425
|
-
className={`text-[
|
|
425
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
426
426
|
viewMode === mode
|
|
427
|
-
? 'bg-[var(--
|
|
427
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
428
428
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
429
429
|
}`}
|
|
430
430
|
>
|
|
@@ -435,9 +435,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
435
435
|
{/* Docs */}
|
|
436
436
|
<button
|
|
437
437
|
onClick={() => setViewMode('docs')}
|
|
438
|
-
className={`text-[
|
|
438
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
439
439
|
viewMode === 'docs'
|
|
440
|
-
? 'bg-[var(--
|
|
440
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
441
441
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
442
442
|
}`}
|
|
443
443
|
>
|
|
@@ -451,9 +451,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
451
451
|
onClick={() => {
|
|
452
452
|
if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
|
|
453
453
|
}}
|
|
454
|
-
className={`text-[
|
|
454
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
455
455
|
['tasks', 'pipelines', 'schedules'].includes(viewMode)
|
|
456
|
-
? 'bg-[var(--
|
|
456
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
457
457
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
458
458
|
}`}
|
|
459
459
|
>
|
|
@@ -468,9 +468,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
468
468
|
{/* Marketplace */}
|
|
469
469
|
<button
|
|
470
470
|
onClick={() => setViewMode('skills')}
|
|
471
|
-
className={`text-[
|
|
471
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
472
472
|
viewMode === 'skills'
|
|
473
|
-
? 'bg-[var(--
|
|
473
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
474
474
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
475
475
|
}`}
|
|
476
476
|
>
|
|
@@ -481,56 +481,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
481
481
|
</div>
|
|
482
482
|
<div className="flex items-center gap-2.5">
|
|
483
483
|
{/* (Automation context actions moved to the secondary toolbar row below) */}
|
|
484
|
-
{/* Help */}
|
|
485
|
-
<button
|
|
486
|
-
onClick={() => setShowHelp(v => !v)}
|
|
487
|
-
className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
|
|
488
|
-
showHelp
|
|
489
|
-
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
490
|
-
: 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
|
|
491
|
-
}`}
|
|
492
|
-
>?</button>
|
|
493
|
-
<div className="relative">
|
|
494
|
-
<button
|
|
495
|
-
onClick={() => setShowBrowserMenu(v => !v)}
|
|
496
|
-
className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
|
|
497
|
-
browserMode !== 'none'
|
|
498
|
-
? 'border-blue-500 text-blue-400'
|
|
499
|
-
: 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
|
|
500
|
-
}`}
|
|
501
|
-
>
|
|
502
|
-
Browser
|
|
503
|
-
</button>
|
|
504
|
-
{showBrowserMenu && (
|
|
505
|
-
<>
|
|
506
|
-
<div className="fixed inset-0 z-40" onClick={() => setShowBrowserMenu(false)} />
|
|
507
|
-
<div className="absolute top-full right-0 mt-1 z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 min-w-[140px]">
|
|
508
|
-
{browserMode !== 'none' && (
|
|
509
|
-
<button onClick={() => { setBrowserMode('none'); setShowBrowserMenu(false); }} className="w-full text-left px-3 py-1.5 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)]">
|
|
510
|
-
Close Browser
|
|
511
|
-
</button>
|
|
512
|
-
)}
|
|
513
|
-
<button onClick={() => { setBrowserMode('float'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'float' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
514
|
-
Floating Window
|
|
515
|
-
</button>
|
|
516
|
-
<button onClick={() => { setBrowserMode('right'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'right' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
517
|
-
Right Side
|
|
518
|
-
</button>
|
|
519
|
-
<button onClick={() => { setBrowserMode('left'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'left' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
520
|
-
Left Side
|
|
521
|
-
</button>
|
|
522
|
-
<button onClick={() => {
|
|
523
|
-
const url = localStorage.getItem('forge-browser-url');
|
|
524
|
-
if (url) window.open(url, '_blank');
|
|
525
|
-
else { const u = prompt('Enter URL to open:'); if (u) window.open(u.trim(), '_blank'); }
|
|
526
|
-
setShowBrowserMenu(false);
|
|
527
|
-
}} className="w-full text-left px-3 py-1.5 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]">
|
|
528
|
-
New Tab
|
|
529
|
-
</button>
|
|
530
|
-
</div>
|
|
531
|
-
</>
|
|
532
|
-
)}
|
|
533
|
-
</div>
|
|
484
|
+
{/* Help / Browser moved into the user dropdown — see below. */}
|
|
534
485
|
<Suspense fallback={null}><TunnelToggle /></Suspense>
|
|
535
486
|
{onlineCount.total > 0 && (
|
|
536
487
|
<span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
|
|
@@ -539,13 +490,15 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
539
490
|
</span>
|
|
540
491
|
)}
|
|
541
492
|
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30" />
|
|
542
|
-
{/* Alerts */}
|
|
493
|
+
{/* Alerts — bell glyph keeps the same role; unread pill rides the bell. */}
|
|
543
494
|
<div className="relative">
|
|
544
495
|
<button
|
|
545
496
|
onClick={() => { setShowNotifications(v => !v); setShowUserMenu(false); }}
|
|
546
|
-
className="text-[
|
|
497
|
+
className="text-[14px] leading-none text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative px-1"
|
|
498
|
+
title="Alerts"
|
|
499
|
+
aria-label="Alerts"
|
|
547
500
|
>
|
|
548
|
-
|
|
501
|
+
<span aria-hidden>🔔</span>
|
|
549
502
|
{unreadCount > 0 && (
|
|
550
503
|
<span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] rounded-full bg-[var(--red)] text-[8px] text-white flex items-center justify-center px-1 font-bold">
|
|
551
504
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
@@ -669,14 +622,17 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
669
622
|
<div className="relative">
|
|
670
623
|
<button
|
|
671
624
|
onClick={() => { setShowUserMenu(v => !v); setShowNotifications(false); }}
|
|
672
|
-
className="text-[
|
|
625
|
+
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
|
|
673
626
|
>
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
627
|
+
<span className="flex flex-col items-end leading-tight">
|
|
628
|
+
{profileDept && (
|
|
629
|
+
<span className="text-[8px] px-1 rounded bg-emerald-500/15 text-emerald-500 border border-emerald-500/30" title="Active department">
|
|
630
|
+
{profileDept}
|
|
631
|
+
</span>
|
|
632
|
+
)}
|
|
633
|
+
<span className="text-[9px]">{displayName}</span>
|
|
634
|
+
</span>
|
|
635
|
+
<span className="text-[8px]">▾</span>
|
|
680
636
|
</button>
|
|
681
637
|
{showUserMenu && (
|
|
682
638
|
<>
|
|
@@ -742,7 +698,49 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
742
698
|
<span className="flex-1">Mobile View</span>
|
|
743
699
|
<span className="text-[9px] text-[var(--text-secondary)]">↗</span>
|
|
744
700
|
</a>
|
|
701
|
+
{/* Browser — toggling reveals the layout options inline so the
|
|
702
|
+
sub-menu lives inside the user dropdown (no nested popup). */}
|
|
703
|
+
<button
|
|
704
|
+
onClick={() => setShowBrowserMenu(v => !v)}
|
|
705
|
+
className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
|
|
706
|
+
>
|
|
707
|
+
<span className="w-3 text-center">🌐</span>
|
|
708
|
+
<span className="flex-1">Browser{browserMode !== 'none' ? ` · ${browserMode}` : ''}</span>
|
|
709
|
+
<span className="text-[9px] text-[var(--text-secondary)]">{showBrowserMenu ? '▾' : '▸'}</span>
|
|
710
|
+
</button>
|
|
711
|
+
{showBrowserMenu && (
|
|
712
|
+
<div className="pl-6 pr-1 py-0.5 border-l border-[var(--border)] ml-3 mb-1">
|
|
713
|
+
<button onClick={() => { setBrowserMode('float'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'float' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
714
|
+
Floating Window
|
|
715
|
+
</button>
|
|
716
|
+
<button onClick={() => { setBrowserMode('right'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'right' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
717
|
+
Right Side
|
|
718
|
+
</button>
|
|
719
|
+
<button onClick={() => { setBrowserMode('left'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'left' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
|
|
720
|
+
Left Side
|
|
721
|
+
</button>
|
|
722
|
+
<button onClick={() => {
|
|
723
|
+
const url = localStorage.getItem('forge-browser-url');
|
|
724
|
+
if (url) window.open(url, '_blank');
|
|
725
|
+
else { const u = prompt('Enter URL to open:'); if (u) window.open(u.trim(), '_blank'); }
|
|
726
|
+
setShowBrowserMenu(false); setShowUserMenu(false);
|
|
727
|
+
}} className="w-full text-left px-2 py-1 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]">
|
|
728
|
+
New Tab
|
|
729
|
+
</button>
|
|
730
|
+
{browserMode !== 'none' && (
|
|
731
|
+
<button onClick={() => { setBrowserMode('none'); setShowBrowserMenu(false); setShowUserMenu(false); }} className="w-full text-left px-2 py-1 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)]">
|
|
732
|
+
Close Browser
|
|
733
|
+
</button>
|
|
734
|
+
)}
|
|
735
|
+
</div>
|
|
736
|
+
)}
|
|
745
737
|
<div className="border-t border-[var(--border)] my-1" />
|
|
738
|
+
<button
|
|
739
|
+
onClick={() => { setShowHelp(v => !v); setShowUserMenu(false); }}
|
|
740
|
+
className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
|
|
741
|
+
>
|
|
742
|
+
<span className="w-3 text-center">?</span><span>Help</span>
|
|
743
|
+
</button>
|
|
746
744
|
<button
|
|
747
745
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
748
746
|
className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--red)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
|
|
@@ -765,9 +763,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
765
763
|
<button
|
|
766
764
|
key={m}
|
|
767
765
|
onClick={() => setViewMode(m)}
|
|
768
|
-
className={`text-[
|
|
766
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
769
767
|
viewMode === m
|
|
770
|
-
? 'bg-[var(--
|
|
768
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
771
769
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
772
770
|
}`}
|
|
773
771
|
>
|