@aion0/forge 0.10.25 → 0.10.27
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 +8 -5
- package/app/api/connectors/[id]/test/route.ts +15 -272
- package/app/api/login-status/[id]/check/route.ts +22 -0
- package/app/api/login-status/route.ts +36 -0
- package/components/Dashboard.tsx +26 -0
- package/components/LoginStatusPanel.tsx +223 -0
- package/docs/blackduck-openapi.yaml +9626 -0
- package/lib/auth/login-status.ts +291 -0
- package/lib/chat/agent-loop.ts +4 -1
- package/lib/chat/protocols/http.ts +125 -9
- package/lib/chat/session-store.ts +32 -9
- package/lib/chat/tool-dispatcher.ts +1 -1
- package/lib/connectors/test-runner.ts +243 -0
- package/lib/connectors/types.ts +58 -1
- package/lib/help-docs/18-chrome-mcp.md +22 -7
- package/package.json +2 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.27
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-02
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.26
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
8
|
+
- fix(chat): atomic tool_use+tool_result cap + raise budget 8k→32k
|
|
9
|
+
- fix(http): scope slash-collapse to pathname only (don't touch query/fragment)
|
|
10
|
+
- fix(http): collapse double-slash in URLs (base_url trailing-slash bug)
|
|
11
|
+
- fix(connector-test): wrap applyAuth + route handler so errors come back as JSON
|
|
9
12
|
|
|
10
13
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
14
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.26...v0.10.27
|
|
@@ -1,283 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* POST /api/connectors/[id]/test
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* {
|
|
9
|
-
* ok: true,
|
|
10
|
-
* status: 200,
|
|
11
|
-
* message: "Authenticated as zliu (Zhen Liu)",
|
|
12
|
-
* duration_ms: 152
|
|
13
|
-
* }
|
|
14
|
-
*
|
|
15
|
-
* On failure:
|
|
16
|
-
*
|
|
17
|
-
* {
|
|
18
|
-
* ok: false,
|
|
19
|
-
* status: 401,
|
|
20
|
-
* error: "Token was revoked. You have to re-authorize from the user.",
|
|
21
|
-
* body_preview: "{...}"
|
|
22
|
-
* }
|
|
23
|
-
*
|
|
24
|
-
* The probe is HTTP-only — connectors that need a different mechanism
|
|
25
|
-
* (browser DOM, shell exec) simply omit `test:` and the UI hides the
|
|
26
|
-
* button.
|
|
4
|
+
* Thin HTTP wrapper around lib/connectors/test-runner. All probe logic
|
|
5
|
+
* lives in the lib so server-side callers (Login Status) can use it
|
|
6
|
+
* without bouncing through this route (which would hit auth middleware).
|
|
27
7
|
*/
|
|
28
8
|
|
|
29
9
|
import { NextResponse } from 'next/server';
|
|
30
|
-
import {
|
|
31
|
-
getConnector,
|
|
32
|
-
getConnectorEntries,
|
|
33
|
-
getInstalledConnector,
|
|
34
|
-
} from '@/lib/connectors/registry';
|
|
35
|
-
import { expandSettingsTokens, expandAllTokens } from '@/lib/plugins/templates';
|
|
36
|
-
import { bridgeRpc } from '@/lib/chat/bridge-client';
|
|
37
|
-
import { applyAuth } from '@/lib/chat/protocols/http';
|
|
38
|
-
import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from '@/lib/connectors/types';
|
|
39
|
-
|
|
40
|
-
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
41
|
-
const MAX_BODY_PREVIEW = 1024;
|
|
42
|
-
|
|
43
|
-
function expandString(s: string, settings: Record<string, unknown>): string {
|
|
44
|
-
return expandAllTokens(s, settings as Record<string, any>, {});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function buildUrl(spec: HttpRequestSpec, settings: Record<string, unknown>): string {
|
|
48
|
-
let url = expandString(spec.url, settings);
|
|
49
|
-
if (spec.query) {
|
|
50
|
-
const u = new URL(url);
|
|
51
|
-
for (const [k, raw] of Object.entries(spec.query)) {
|
|
52
|
-
u.searchParams.set(k, expandString(String(raw), settings));
|
|
53
|
-
}
|
|
54
|
-
url = u.toString();
|
|
55
|
-
}
|
|
56
|
-
return url;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function buildHeaders(spec: HttpRequestSpec, settings: Record<string, unknown>): Headers {
|
|
60
|
-
const h = new Headers();
|
|
61
|
-
if (spec.headers) {
|
|
62
|
-
for (const [k, raw] of Object.entries(spec.headers)) {
|
|
63
|
-
h.set(k, expandString(String(raw), settings));
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return h;
|
|
67
|
-
}
|
|
10
|
+
import { runConnectorTest } from '@/lib/connectors/test-runner';
|
|
68
11
|
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
settings: Record<string, unknown>,
|
|
72
|
-
): { body?: string; contentType?: string } {
|
|
73
|
-
if (spec.body == null) return {};
|
|
74
|
-
if (typeof spec.body === 'string') {
|
|
75
|
-
return { body: expandString(spec.body, settings) };
|
|
76
|
-
}
|
|
77
|
-
const out: Record<string, unknown> = {};
|
|
78
|
-
for (const [k, v] of Object.entries(spec.body)) {
|
|
79
|
-
out[k] = typeof v === 'string' ? expandString(v, settings) : v;
|
|
80
|
-
}
|
|
81
|
-
return { body: JSON.stringify(out), contentType: 'application/json' };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Render `{{path.to.value}}` placeholders against a parsed JSON body.
|
|
86
|
-
* Missing paths render as "?", non-string scalars get stringified.
|
|
87
|
-
*/
|
|
88
|
-
function renderTemplate(template: string, body: unknown): string {
|
|
89
|
-
return template.replace(/\{\{([^{}]+)\}\}/g, (_match, expr) => {
|
|
90
|
-
const path = String(expr).trim().split('.').filter(Boolean);
|
|
91
|
-
let cur: any = body;
|
|
92
|
-
for (const p of path) {
|
|
93
|
-
if (cur && typeof cur === 'object' && p in cur) cur = cur[p];
|
|
94
|
-
else return '?';
|
|
95
|
-
}
|
|
96
|
-
if (cur == null) return '?';
|
|
97
|
-
return typeof cur === 'string' ? cur : JSON.stringify(cur);
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
interface TestResult {
|
|
102
|
-
ok: boolean;
|
|
103
|
-
status?: number;
|
|
104
|
-
message?: string;
|
|
105
|
-
error?: string;
|
|
106
|
-
duration_ms?: number;
|
|
107
|
-
body_preview?: string;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function runHttpProbe(test: ConnectorTest, settings: Record<string, unknown>, def: ConnectorDefinition): Promise<TestResult> {
|
|
111
|
-
const spec = test.request;
|
|
112
|
-
if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };
|
|
113
|
-
|
|
114
|
-
// Multi-instance overlay: test probe always uses the first instance
|
|
115
|
-
// (same guard as tool-dispatcher — only kicks in when instances is a
|
|
116
|
-
// well-formed array, so single-instance connectors are unaffected).
|
|
117
|
-
let effectiveSettings = settings as Record<string, any>;
|
|
118
|
-
let instances = effectiveSettings?.instances;
|
|
119
|
-
// type: json fields persist as strings — parse before checking.
|
|
120
|
-
if (typeof instances === 'string') {
|
|
121
|
-
try { instances = JSON.parse(instances); } catch { instances = null; }
|
|
122
|
-
}
|
|
123
|
-
if (
|
|
124
|
-
Array.isArray(instances) &&
|
|
125
|
-
instances.length > 0 &&
|
|
126
|
-
instances.every((i: any) => i && typeof i === 'object' && typeof i.name === 'string')
|
|
127
|
-
) {
|
|
128
|
-
effectiveSettings = { ...effectiveSettings, ...instances[0] };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const method = (spec.method || 'GET').toUpperCase();
|
|
132
|
-
let url = buildUrl(spec, effectiveSettings);
|
|
133
|
-
const headers = buildHeaders(spec, effectiveSettings);
|
|
134
|
-
const { body, contentType } = buildBody(spec, effectiveSettings);
|
|
135
|
-
if (body != null && contentType && !headers.has('content-type')) {
|
|
136
|
-
headers.set('content-type', contentType);
|
|
137
|
-
}
|
|
138
|
-
// Apply connector-level auth so the test probe uses the same scheme
|
|
139
|
-
// as live tool calls (e.g. Basic auth for Jenkins). Manifests that
|
|
140
|
-
// hand-craft Authorization in test.request.headers still work — the
|
|
141
|
-
// auth scheme would just overwrite the header.
|
|
142
|
-
url = applyAuth(url, headers, def.auth, effectiveSettings);
|
|
143
|
-
|
|
144
|
-
const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
|
|
145
|
-
const okStatus = test.ok_status?.length ? test.ok_status : [200];
|
|
146
|
-
|
|
147
|
-
const ctrl = new AbortController();
|
|
148
|
-
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
149
|
-
const t0 = Date.now();
|
|
150
|
-
let res: Response;
|
|
151
|
-
try {
|
|
152
|
-
res = await fetch(url, { method, headers, body, signal: ctrl.signal });
|
|
153
|
-
} catch (e) {
|
|
154
|
-
clearTimeout(timer);
|
|
155
|
-
const err = e as Error & { cause?: unknown };
|
|
156
|
-
const cause = err.cause instanceof Error ? `: ${err.cause.message}` : '';
|
|
157
|
-
return {
|
|
158
|
-
ok: false,
|
|
159
|
-
error: `request failed: ${err.message}${cause}`,
|
|
160
|
-
duration_ms: Date.now() - t0,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
clearTimeout(timer);
|
|
164
|
-
|
|
165
|
-
const duration = Date.now() - t0;
|
|
166
|
-
const text = await res.text().catch(() => '');
|
|
167
|
-
const preview = text.length > MAX_BODY_PREVIEW ? text.slice(0, MAX_BODY_PREVIEW) + '…' : text;
|
|
168
|
-
|
|
169
|
-
if (!okStatus.includes(res.status)) {
|
|
170
|
-
let errMsg = `HTTP ${res.status} ${res.statusText}`;
|
|
171
|
-
try {
|
|
172
|
-
const j = JSON.parse(text);
|
|
173
|
-
if (typeof j?.error === 'string') errMsg += `: ${j.error}`;
|
|
174
|
-
else if (typeof j?.message === 'string') errMsg += `: ${j.message}`;
|
|
175
|
-
else if (typeof j?.error_description === 'string') errMsg += `: ${j.error_description}`;
|
|
176
|
-
} catch {}
|
|
177
|
-
return {
|
|
178
|
-
ok: false,
|
|
179
|
-
status: res.status,
|
|
180
|
-
error: errMsg,
|
|
181
|
-
duration_ms: duration,
|
|
182
|
-
body_preview: preview,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
let parsedBody: unknown = null;
|
|
187
|
-
try { parsedBody = JSON.parse(text); } catch {}
|
|
188
|
-
const message = test.ok_template
|
|
189
|
-
? renderTemplate(test.ok_template, parsedBody)
|
|
190
|
-
: `OK (HTTP ${res.status})`;
|
|
191
|
-
return {
|
|
192
|
-
ok: true,
|
|
193
|
-
status: res.status,
|
|
194
|
-
message,
|
|
195
|
-
duration_ms: duration,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Browser probe: ask the paired extension to land on the connector's
|
|
201
|
-
* host_match URL and report whether `login_redirect` was hit.
|
|
202
|
-
*
|
|
203
|
-
* The extension implements `connector.probe` (see
|
|
204
|
-
* lib/help-docs/21-build-connector.md for the wire contract). On
|
|
205
|
-
* sites the user is logged in to, it returns
|
|
206
|
-
* { ok: true, url: '<final tab URL>' }
|
|
207
|
-
* On a redirect to the login page it returns
|
|
208
|
-
* { ok: false, error: 'login required', url: '<login url>' }
|
|
209
|
-
* On extension absence we throw, caller surfaces it.
|
|
210
|
-
*/
|
|
211
|
-
async function runBrowserProbe(
|
|
212
|
-
def: ConnectorDefinition,
|
|
213
|
-
settings: Record<string, unknown>,
|
|
214
|
-
): Promise<TestResult> {
|
|
215
|
-
const entries = getConnectorEntries(def);
|
|
216
|
-
const entry = entries[0];
|
|
217
|
-
const hostMatch = def.host_match || entry?.host_match;
|
|
218
|
-
const loginRedirect = def.login_redirect || entry?.login_redirect;
|
|
219
|
-
if (!hostMatch) {
|
|
220
|
-
return { ok: false, error: 'browser probe requires host_match on the manifest' };
|
|
221
|
-
}
|
|
222
|
-
const expandedHost = expandSettingsTokens(hostMatch, settings as any);
|
|
223
|
-
const expandedLoginRedirect = loginRedirect
|
|
224
|
-
? expandSettingsTokens(loginRedirect, settings as any)
|
|
225
|
-
: undefined;
|
|
226
|
-
|
|
227
|
-
const t0 = Date.now();
|
|
228
|
-
let value: unknown;
|
|
12
|
+
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
13
|
+
const { id } = await params;
|
|
229
14
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
login_redirect: expandedLoginRedirect,
|
|
234
|
-
runner: def.runner || entry?.runner || 'main',
|
|
235
|
-
timeout_ms: def.test?.timeout_ms || 30_000,
|
|
236
|
-
});
|
|
15
|
+
const r = await runConnectorTest(id);
|
|
16
|
+
const { code, ...body } = r;
|
|
17
|
+
return NextResponse.json(body, code ? { status: code } : undefined);
|
|
237
18
|
} catch (e) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
} else if (raw.includes('bridge') && raw.includes('unreachable')) {
|
|
245
|
-
friendly =
|
|
246
|
-
'Forge browser bridge is unreachable on port 8407. Restart Forge (forge server restart) or check that the browser-bridge standalone is running.';
|
|
247
|
-
} else if (raw.includes('no paired extensions') || raw.includes('no connected')) {
|
|
248
|
-
friendly =
|
|
249
|
-
'Forge browser extension not connected. Install the extension from forge-browser-extension/dist, pin it, and sign in with your Forge URL + admin password.';
|
|
250
|
-
}
|
|
251
|
-
return {
|
|
252
|
-
ok: false,
|
|
253
|
-
error: friendly,
|
|
254
|
-
duration_ms: Date.now() - t0,
|
|
255
|
-
};
|
|
19
|
+
// Belt-and-suspenders: never let an uncaught exception bubble to
|
|
20
|
+
// Next's HTML 500 page — the browser would then choke on JSON parse.
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ ok: false, error: `internal error: ${(e as Error).message}` },
|
|
23
|
+
{ status: 500 },
|
|
24
|
+
);
|
|
256
25
|
}
|
|
257
|
-
const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
|
|
258
|
-
return {
|
|
259
|
-
ok: !!r.ok,
|
|
260
|
-
message: r.ok ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
|
|
261
|
-
error: r.ok ? undefined : (r.error || 'login required'),
|
|
262
|
-
duration_ms: Date.now() - t0,
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
267
|
-
const { id } = await params;
|
|
268
|
-
const def = getConnector(id);
|
|
269
|
-
if (!def) return NextResponse.json({ ok: false, error: 'connector not found' }, { status: 404 });
|
|
270
|
-
if (!def.test) {
|
|
271
|
-
return NextResponse.json({ ok: false, error: 'connector has no test block' }, { status: 400 });
|
|
272
|
-
}
|
|
273
|
-
const inst = getInstalledConnector(id);
|
|
274
|
-
if (!inst) {
|
|
275
|
-
return NextResponse.json({ ok: false, error: 'connector not installed' }, { status: 400 });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const probe = def.test.probe || 'http';
|
|
279
|
-
const r = probe === 'browser'
|
|
280
|
-
? await runBrowserProbe(def, inst.config)
|
|
281
|
-
: await runHttpProbe(def.test, inst.config, def);
|
|
282
|
-
return NextResponse.json(r);
|
|
283
26
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/login-status/[id]/check — run one source's check, update cache.
|
|
3
|
+
*/
|
|
4
|
+
import { NextResponse } from 'next/server';
|
|
5
|
+
import { listSources, checkSource } from '@/lib/auth/login-status';
|
|
6
|
+
|
|
7
|
+
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
8
|
+
const { id } = await params;
|
|
9
|
+
const source = listSources().find((s) => s.id === id);
|
|
10
|
+
if (!source) {
|
|
11
|
+
return NextResponse.json({ ok: false, error: `unknown source: ${id}` }, { status: 404 });
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const result = await checkSource(source);
|
|
15
|
+
return NextResponse.json({ source, result });
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return NextResponse.json(
|
|
18
|
+
{ ok: false, error: `check failed: ${(e as Error).message}` },
|
|
19
|
+
{ status: 500 },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/login-status → list all sources + their cached
|
|
3
|
+
* last-check result
|
|
4
|
+
* POST /api/login-status → run all checks in parallel,
|
|
5
|
+
* return fresh results
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NextResponse } from 'next/server';
|
|
9
|
+
import { listSources, listStatus, checkSource } from '@/lib/auth/login-status';
|
|
10
|
+
|
|
11
|
+
export async function GET() {
|
|
12
|
+
return NextResponse.json({ rows: listStatus() });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function POST() {
|
|
16
|
+
const sources = listSources();
|
|
17
|
+
const results = await Promise.all(
|
|
18
|
+
sources.map(async (s) => {
|
|
19
|
+
try {
|
|
20
|
+
const r = await checkSource(s);
|
|
21
|
+
return { source: s, result: r };
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return {
|
|
24
|
+
source: s,
|
|
25
|
+
result: {
|
|
26
|
+
ok: false,
|
|
27
|
+
message: `check error: ${(e as Error).message}`,
|
|
28
|
+
checked_at: Date.now(),
|
|
29
|
+
duration_ms: 0,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
return NextResponse.json({ rows: results });
|
|
36
|
+
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -24,6 +24,7 @@ const SessionView = lazy(() => import('./SessionView'));
|
|
|
24
24
|
const NewTaskModal = lazy(() => import('./NewTaskModal'));
|
|
25
25
|
const SettingsModal = lazy(() => import('./SettingsModal'));
|
|
26
26
|
const MonitorPanel = lazy(() => import('./MonitorPanel'));
|
|
27
|
+
const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
|
|
27
28
|
const WorkspaceView = lazy(() => import('./WorkspaceView'));
|
|
28
29
|
// WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
|
|
29
30
|
|
|
@@ -132,6 +133,8 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
132
133
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
133
134
|
const [showSettings, setShowSettings] = useState(false);
|
|
134
135
|
const [showMonitor, setShowMonitor] = useState(false);
|
|
136
|
+
const [showLoginStatus, setShowLoginStatus] = useState(false);
|
|
137
|
+
const [loginBadge, setLoginBadge] = useState<{ broken: number; total: number } | null>(null);
|
|
135
138
|
const [showHelp, setShowHelp] = useState(false);
|
|
136
139
|
const [usage, setUsage] = useState<UsageSummary[]>([]);
|
|
137
140
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
@@ -212,6 +215,19 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
212
215
|
return () => clearInterval(id);
|
|
213
216
|
}, []);
|
|
214
217
|
|
|
218
|
+
// Login status badge — load cached results on mount so the user
|
|
219
|
+
// dropdown shows the red dot count without forcing a check.
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
fetch('/api/login-status')
|
|
222
|
+
.then((r) => r.json())
|
|
223
|
+
.then((j) => {
|
|
224
|
+
const rows = (j.rows || []) as Array<{ result: { ok: boolean } | null }>;
|
|
225
|
+
const broken = rows.filter((r) => r.result && !r.result.ok).length;
|
|
226
|
+
setLoginBadge({ broken, total: rows.length });
|
|
227
|
+
})
|
|
228
|
+
.catch(() => {});
|
|
229
|
+
}, []);
|
|
230
|
+
|
|
215
231
|
// Notifications: poll unread count at 30s, full fetch when panel opens
|
|
216
232
|
const fetchNotifications = useCallback(() => {
|
|
217
233
|
fetch('/api/notifications').then(r => r.json()).then(data => {
|
|
@@ -609,6 +625,15 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
609
625
|
>
|
|
610
626
|
Monitor
|
|
611
627
|
</button>
|
|
628
|
+
<button
|
|
629
|
+
onClick={() => { setShowLoginStatus(true); setShowUserMenu(false); }}
|
|
630
|
+
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 justify-between"
|
|
631
|
+
>
|
|
632
|
+
<span>Login Status</span>
|
|
633
|
+
{loginBadge && loginBadge.broken > 0 && (
|
|
634
|
+
<span className="text-[9px] text-red-400">🔴 {loginBadge.broken}/{loginBadge.total}</span>
|
|
635
|
+
)}
|
|
636
|
+
</button>
|
|
612
637
|
<button
|
|
613
638
|
onClick={() => { setShowSettings(true); setShowUserMenu(false); }}
|
|
614
639
|
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)]"
|
|
@@ -908,6 +933,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
908
933
|
)}
|
|
909
934
|
|
|
910
935
|
{showMonitor && <Suspense fallback={null}><MonitorPanel onClose={() => setShowMonitor(false)} /></Suspense>}
|
|
936
|
+
{showLoginStatus && <Suspense fallback={null}><LoginStatusPanel onClose={() => { setShowLoginStatus(false); /* refresh badge after panel actions */ fetch('/api/login-status').then(r => r.json()).then(j => { const rows = j.rows || []; const broken = rows.filter((r: any) => r.result && !r.result.ok).length; setLoginBadge({ broken, total: rows.length }); }).catch(() => {}); }} /></Suspense>}
|
|
911
937
|
|
|
912
938
|
{showSettings && (
|
|
913
939
|
<Suspense fallback={null}>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
type LoginCategory = 'browser' | 'token' | 'external';
|
|
6
|
+
|
|
7
|
+
type RefreshHint =
|
|
8
|
+
| { kind: 'open-url'; url: string; description?: string }
|
|
9
|
+
| { kind: 'show-command'; command: string; description: string }
|
|
10
|
+
| { kind: 'open-settings'; section: string };
|
|
11
|
+
|
|
12
|
+
interface LoginSource {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
category: LoginCategory;
|
|
16
|
+
detail?: string;
|
|
17
|
+
refresh: RefreshHint;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface LoginCheckResult {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
message?: string;
|
|
23
|
+
landed_url?: string;
|
|
24
|
+
checked_at: number;
|
|
25
|
+
duration_ms: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Row {
|
|
29
|
+
source: LoginSource;
|
|
30
|
+
result: LoginCheckResult | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CATEGORY_LABEL: Record<LoginCategory, string> = {
|
|
34
|
+
browser: 'Browser Sessions',
|
|
35
|
+
token: 'API Tokens',
|
|
36
|
+
external: 'External Auth',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function timeAgo(ts: number): string {
|
|
40
|
+
const s = Math.round((Date.now() - ts) / 1000);
|
|
41
|
+
if (s < 60) return `${s}s ago`;
|
|
42
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
43
|
+
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
|
44
|
+
return `${Math.round(s / 86400)}d ago`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function LoginStatusPanel({ onClose }: { onClose: () => void }) {
|
|
48
|
+
const [rows, setRows] = useState<Row[] | null>(null);
|
|
49
|
+
const [busyAll, setBusyAll] = useState(false);
|
|
50
|
+
const [busyIds, setBusyIds] = useState<Set<string>>(new Set());
|
|
51
|
+
const [showCmd, setShowCmd] = useState<{ command: string; description: string } | null>(null);
|
|
52
|
+
|
|
53
|
+
const loadCached = useCallback(() => {
|
|
54
|
+
fetch('/api/login-status')
|
|
55
|
+
.then((r) => r.json())
|
|
56
|
+
.then((j) => setRows(j.rows || []))
|
|
57
|
+
.catch(() => {});
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
useEffect(() => { loadCached(); }, [loadCached]);
|
|
61
|
+
|
|
62
|
+
const checkAll = async () => {
|
|
63
|
+
setBusyAll(true);
|
|
64
|
+
try {
|
|
65
|
+
const r = await fetch('/api/login-status', { method: 'POST' });
|
|
66
|
+
const j = await r.json();
|
|
67
|
+
setRows(j.rows || []);
|
|
68
|
+
} finally {
|
|
69
|
+
setBusyAll(false);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const checkOne = async (id: string) => {
|
|
74
|
+
setBusyIds((s) => new Set(s).add(id));
|
|
75
|
+
try {
|
|
76
|
+
const r = await fetch(`/api/login-status/${encodeURIComponent(id)}/check`, { method: 'POST' });
|
|
77
|
+
const j = await r.json();
|
|
78
|
+
if (j.source && j.result) {
|
|
79
|
+
setRows((prev) =>
|
|
80
|
+
(prev || []).map((row) => (row.source.id === id ? { source: j.source, result: j.result } : row)),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
setBusyIds((s) => {
|
|
85
|
+
const next = new Set(s); next.delete(id); return next;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const refresh = (source: LoginSource) => {
|
|
91
|
+
const r = source.refresh;
|
|
92
|
+
if (r.kind === 'open-url') {
|
|
93
|
+
window.open(r.url, '_blank', 'noopener');
|
|
94
|
+
} else if (r.kind === 'show-command') {
|
|
95
|
+
setShowCmd({ command: r.command, description: r.description });
|
|
96
|
+
} else if (r.kind === 'open-settings') {
|
|
97
|
+
window.dispatchEvent(new CustomEvent('forge:open-settings', { detail: { section: r.section } }));
|
|
98
|
+
onClose();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const grouped: Record<LoginCategory, Row[]> = { browser: [], token: [], external: [] };
|
|
103
|
+
(rows || []).forEach((row) => grouped[row.source.category].push(row));
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
|
107
|
+
<div
|
|
108
|
+
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[560px] max-h-[85vh] overflow-y-auto shadow-xl"
|
|
109
|
+
onClick={(e) => e.stopPropagation()}
|
|
110
|
+
>
|
|
111
|
+
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
|
|
112
|
+
<h2 className="text-sm font-bold text-[var(--text-primary)]">Login Status</h2>
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<button
|
|
115
|
+
onClick={checkAll}
|
|
116
|
+
disabled={busyAll || rows === null}
|
|
117
|
+
className="text-[10px] px-2 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
118
|
+
>
|
|
119
|
+
{busyAll ? 'Checking…' : 'Check all'}
|
|
120
|
+
</button>
|
|
121
|
+
<button onClick={onClose} className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
|
|
122
|
+
Close
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{rows === null ? (
|
|
128
|
+
<div className="p-6 text-center text-xs text-[var(--text-secondary)]">Loading…</div>
|
|
129
|
+
) : rows.length === 0 ? (
|
|
130
|
+
<div className="p-6 text-center text-xs text-[var(--text-secondary)]">
|
|
131
|
+
No login sources registered. Add a connector with a <code>test:</code> block or set <code>gitlab2faProbeRepo</code> in Settings.
|
|
132
|
+
</div>
|
|
133
|
+
) : (
|
|
134
|
+
<div className="p-4 space-y-5">
|
|
135
|
+
{(['browser', 'token', 'external'] as LoginCategory[]).map((cat) => {
|
|
136
|
+
const items = grouped[cat];
|
|
137
|
+
if (!items.length) return null;
|
|
138
|
+
return (
|
|
139
|
+
<div key={cat}>
|
|
140
|
+
<h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">
|
|
141
|
+
{CATEGORY_LABEL[cat]} ({items.length})
|
|
142
|
+
</h3>
|
|
143
|
+
<div className="space-y-1.5">
|
|
144
|
+
{items.map(({ source, result }) => {
|
|
145
|
+
const busy = busyIds.has(source.id);
|
|
146
|
+
const dot = !result ? '⚪' : result.ok ? '🟢' : '🔴';
|
|
147
|
+
return (
|
|
148
|
+
<div key={source.id} className="flex items-start gap-2 text-xs">
|
|
149
|
+
<span className="mt-0.5">{dot}</span>
|
|
150
|
+
<div className="flex-1 min-w-0">
|
|
151
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
152
|
+
<span className="text-[var(--text-primary)] font-medium">{source.label}</span>
|
|
153
|
+
{result ? (
|
|
154
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{timeAgo(result.checked_at)}</span>
|
|
155
|
+
) : (
|
|
156
|
+
<span className="text-[10px] text-gray-500">not checked</span>
|
|
157
|
+
)}
|
|
158
|
+
<div className="flex items-center gap-1 ml-auto">
|
|
159
|
+
<button
|
|
160
|
+
onClick={() => checkOne(source.id)}
|
|
161
|
+
disabled={busy || busyAll}
|
|
162
|
+
className="text-[10px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
163
|
+
>
|
|
164
|
+
{busy ? '…' : 'Check'}
|
|
165
|
+
</button>
|
|
166
|
+
<button
|
|
167
|
+
onClick={() => refresh(source)}
|
|
168
|
+
className="text-[10px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
169
|
+
>
|
|
170
|
+
{source.refresh.kind === 'open-url' ? 'Open login' :
|
|
171
|
+
source.refresh.kind === 'show-command' ? 'Refresh' :
|
|
172
|
+
'Configure'}
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
{result?.message && (
|
|
177
|
+
<div className={`text-[10px] mt-0.5 ${result.ok ? 'text-[var(--text-secondary)]' : 'text-red-400'}`}>
|
|
178
|
+
{result.message}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
{source.detail && !result?.message && (
|
|
182
|
+
<div className="text-[10px] text-gray-500 mt-0.5">{source.detail}</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
})}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
})}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{showCmd && (
|
|
196
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setShowCmd(null)}>
|
|
197
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4 w-[480px] shadow-xl" onClick={(e) => e.stopPropagation()}>
|
|
198
|
+
<h3 className="text-sm font-bold mb-2 text-[var(--text-primary)]">Run this in a terminal</h3>
|
|
199
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-2">{showCmd.description}</div>
|
|
200
|
+
<pre className="bg-[var(--bg-tertiary)] p-2 rounded text-xs text-[var(--text-primary)] font-mono whitespace-pre-wrap break-all">
|
|
201
|
+
{showCmd.command}
|
|
202
|
+
</pre>
|
|
203
|
+
<div className="mt-3 flex items-center justify-end gap-2">
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => { navigator.clipboard.writeText(showCmd.command); }}
|
|
206
|
+
className="text-[10px] px-2 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
207
|
+
>
|
|
208
|
+
Copy
|
|
209
|
+
</button>
|
|
210
|
+
<button
|
|
211
|
+
onClick={() => setShowCmd(null)}
|
|
212
|
+
className="text-[10px] px-2 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
213
|
+
>
|
|
214
|
+
Close
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|