fraim 2.0.189 → 2.0.190
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/bin/fraim.js +1 -1
- package/dist/src/ai-hub/desktop-main.js +80 -0
- package/dist/src/ai-hub/server.js +21 -5
- package/dist/src/routes/app-routes.js +93 -0
- package/dist/src/routes/auth-routes.js +5 -1
- package/dist/src/routes/oauth-routes.js +43 -2
- package/package.json +1 -1
- package/public/ai-hub/script.js +288 -159
package/bin/fraim.js
CHANGED
|
@@ -62,6 +62,55 @@ function isTrustedInAppNavigation(targetUrl, hubUrl) {
|
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
function connectedSurfaceParam(value) {
|
|
66
|
+
return value === 'account' || value === 'analytics' || value === 'brain' ? value : null;
|
|
67
|
+
}
|
|
68
|
+
function connectedSurfaceForHostedUrl(targetUrl, hubUrl) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = new URL(targetUrl);
|
|
71
|
+
const hub = new URL(hubUrl);
|
|
72
|
+
const remote = new URL((0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
|
|
73
|
+
if (parsed.origin === hub.origin || parsed.origin !== remote.origin)
|
|
74
|
+
return null;
|
|
75
|
+
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
|
76
|
+
if (pathname === '/account')
|
|
77
|
+
return 'account';
|
|
78
|
+
if (pathname === '/analytics')
|
|
79
|
+
return 'analytics';
|
|
80
|
+
if (pathname === '/fraim-brain' || pathname === '/fraim-brain.html')
|
|
81
|
+
return 'brain';
|
|
82
|
+
if (pathname === '/ai-hub') {
|
|
83
|
+
return connectedSurfaceParam(parsed.searchParams.get('connected') || parsed.searchParams.get('open'));
|
|
84
|
+
}
|
|
85
|
+
if (pathname === '/auth/sign-in.html' || pathname === '/auth/recovery.html' || pathname === '/auth/error.html') {
|
|
86
|
+
return connectedSurfaceParam(parsed.searchParams.get('surface'));
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function localHubConnectedUrl(hubUrl, surface, sourceUrl) {
|
|
95
|
+
const target = new URL(hubUrl);
|
|
96
|
+
target.search = '';
|
|
97
|
+
target.hash = '';
|
|
98
|
+
target.searchParams.set('connected', surface);
|
|
99
|
+
if (sourceUrl) {
|
|
100
|
+
try {
|
|
101
|
+
const source = new URL(sourceUrl);
|
|
102
|
+
const apiKey = source.searchParams.get('api-key') || source.searchParams.get('apiKey');
|
|
103
|
+
if (apiKey)
|
|
104
|
+
target.searchParams.set('api-key', apiKey);
|
|
105
|
+
}
|
|
106
|
+
catch { /* ignore malformed source URLs */ }
|
|
107
|
+
}
|
|
108
|
+
return target.toString();
|
|
109
|
+
}
|
|
110
|
+
function wrappedHostedNavigationUrl(targetUrl, hubUrl) {
|
|
111
|
+
const surface = connectedSurfaceForHostedUrl(targetUrl, hubUrl);
|
|
112
|
+
return surface ? localHubConnectedUrl(hubUrl, surface, targetUrl) : null;
|
|
113
|
+
}
|
|
65
114
|
// ---------------------------------------------------------------------------
|
|
66
115
|
// Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
|
|
67
116
|
// so the app never crashes if assets aren't present.
|
|
@@ -216,11 +265,42 @@ async function createWindow(url) {
|
|
|
216
265
|
mainWindow.on('closed', () => electron_1.nativeTheme.removeListener('updated', applyOverlay));
|
|
217
266
|
}
|
|
218
267
|
mainWindow.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
|
|
268
|
+
const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
|
|
269
|
+
if (wrappedUrl) {
|
|
270
|
+
void mainWindow?.loadURL(wrappedUrl);
|
|
271
|
+
return { action: 'deny' };
|
|
272
|
+
}
|
|
219
273
|
if (isTrustedInAppNavigation(targetUrl, url)) {
|
|
220
274
|
void mainWindow?.loadURL(targetUrl);
|
|
221
275
|
}
|
|
222
276
|
return { action: 'deny' };
|
|
223
277
|
});
|
|
278
|
+
mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
|
|
279
|
+
const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
|
|
280
|
+
if (!wrappedUrl)
|
|
281
|
+
return;
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
if (mainWindow?.webContents.getURL() !== wrappedUrl) {
|
|
284
|
+
void mainWindow?.loadURL(wrappedUrl);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
mainWindow.webContents.on('will-redirect', (details) => {
|
|
288
|
+
if (!details.isMainFrame)
|
|
289
|
+
return;
|
|
290
|
+
const wrappedUrl = wrappedHostedNavigationUrl(details.url, url);
|
|
291
|
+
if (!wrappedUrl)
|
|
292
|
+
return;
|
|
293
|
+
details.preventDefault();
|
|
294
|
+
if (mainWindow?.webContents.getURL() !== wrappedUrl) {
|
|
295
|
+
void mainWindow?.loadURL(wrappedUrl);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
mainWindow.webContents.on('did-navigate', (_event, targetUrl) => {
|
|
299
|
+
const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
|
|
300
|
+
if (!wrappedUrl || mainWindow?.webContents.getURL() === wrappedUrl)
|
|
301
|
+
return;
|
|
302
|
+
void mainWindow?.loadURL(wrappedUrl);
|
|
303
|
+
});
|
|
224
304
|
mainWindow.once('ready-to-show', () => mainWindow?.show());
|
|
225
305
|
// Closing the window hides it to the tray rather than quitting the app.
|
|
226
306
|
// The server keeps running so Word can still reach the task pane.
|
|
@@ -128,10 +128,19 @@ function buildHubPersonaHireUrl(personaKey, hireMode = 'job') {
|
|
|
128
128
|
const params = new URLSearchParams({ persona: personaKey, mode: hireMode });
|
|
129
129
|
return `/pricing?${params.toString()}`;
|
|
130
130
|
}
|
|
131
|
-
function
|
|
131
|
+
function buildLocalHubReturnUrl(req, surface) {
|
|
132
|
+
const proto = req.protocol || 'http';
|
|
133
|
+
const host = req.get('host') || '127.0.0.1';
|
|
134
|
+
const target = new URL('/ai-hub/', `${proto}://${host}`);
|
|
135
|
+
target.searchParams.set('connected', surface);
|
|
136
|
+
return target.toString();
|
|
137
|
+
}
|
|
138
|
+
function buildHostedAuthUrl(surface, redirectTo, hubReturn) {
|
|
132
139
|
const target = new URL('/auth/sign-in.html', (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
|
|
133
140
|
target.searchParams.set('surface', surface);
|
|
134
141
|
target.searchParams.set('redirect_to', redirectTo);
|
|
142
|
+
if (hubReturn)
|
|
143
|
+
target.searchParams.set('hub_return', hubReturn);
|
|
135
144
|
return target.toString();
|
|
136
145
|
}
|
|
137
146
|
function buildHostedPathUrl(pathname, queryString) {
|
|
@@ -2012,13 +2021,20 @@ class AiHubServer {
|
|
|
2012
2021
|
if (!this.dbService) {
|
|
2013
2022
|
this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
|
|
2014
2023
|
const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
|
|
2024
|
+
if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
|
|
2025
|
+
const rawSurface = parsed.searchParams.get('surface');
|
|
2026
|
+
const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
|
|
2027
|
+
? rawSurface
|
|
2028
|
+
: 'analytics';
|
|
2029
|
+
parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
|
|
2030
|
+
}
|
|
2015
2031
|
res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
|
|
2016
2032
|
});
|
|
2017
|
-
this.app.get(['/account', '/account/'], (
|
|
2018
|
-
res.redirect(buildHostedAuthUrl('account', '/account/'));
|
|
2033
|
+
this.app.get(['/account', '/account/'], (req, res) => {
|
|
2034
|
+
res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
|
|
2019
2035
|
});
|
|
2020
|
-
this.app.get(['/analytics', '/analytics/'], (
|
|
2021
|
-
res.redirect(buildHostedAuthUrl('analytics', '/analytics/'));
|
|
2036
|
+
this.app.get(['/analytics', '/analytics/'], (req, res) => {
|
|
2037
|
+
res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
|
|
2022
2038
|
});
|
|
2023
2039
|
}
|
|
2024
2040
|
else {
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.registerAppRoutes = registerAppRoutes;
|
|
7
|
+
exports.registerProtectedAppRoutes = registerProtectedAppRoutes;
|
|
7
8
|
const path_1 = __importDefault(require("path"));
|
|
8
9
|
/**
|
|
9
10
|
* Serve the unified tabs experience at the root path.
|
|
@@ -30,3 +31,95 @@ function registerAppRoutes(app, deps) {
|
|
|
30
31
|
}
|
|
31
32
|
});
|
|
32
33
|
}
|
|
34
|
+
function requestBaseUrl(req) {
|
|
35
|
+
const fromEnv = process.env.FRAIM_PUBLIC_BASE_URL;
|
|
36
|
+
if (fromEnv && /^https?:\/\//i.test(fromEnv))
|
|
37
|
+
return fromEnv.replace(/\/+$/, '');
|
|
38
|
+
const firstHeader = (value) => Array.isArray(value) ? value[0] : value;
|
|
39
|
+
const proto = firstHeader(req.headers['x-forwarded-proto']) || req.protocol || 'http';
|
|
40
|
+
const host = firstHeader(req.headers['x-forwarded-host']) || req.get('host') || 'localhost';
|
|
41
|
+
return `${proto.split(',')[0]}://${host.split(',')[0]}`.replace(/\/+$/, '');
|
|
42
|
+
}
|
|
43
|
+
function hostedProjectSummary() {
|
|
44
|
+
const projectPath = process.cwd();
|
|
45
|
+
return {
|
|
46
|
+
path: projectPath,
|
|
47
|
+
exists: true,
|
|
48
|
+
hasFraim: true,
|
|
49
|
+
needsOnboarding: false,
|
|
50
|
+
message: '',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function hostedTeamContext() {
|
|
54
|
+
const emptyFile = { present: false, displayPath: '' };
|
|
55
|
+
return {
|
|
56
|
+
orgContext: emptyFile,
|
|
57
|
+
managerContext: emptyFile,
|
|
58
|
+
orgRules: emptyFile,
|
|
59
|
+
managerRules: emptyFile,
|
|
60
|
+
projectContext: emptyFile,
|
|
61
|
+
projectBrief: emptyFile,
|
|
62
|
+
projectQa: emptyFile,
|
|
63
|
+
projectRules: emptyFile,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Protected app routes for hosted web surfaces.
|
|
68
|
+
*
|
|
69
|
+
* The deployed server owns authentication/session identity and can provide a
|
|
70
|
+
* narrow bootstrap contract for the hosted Hub shell. It intentionally does not
|
|
71
|
+
* instantiate the local AiHubServer runtime, which belongs to the companion app.
|
|
72
|
+
*/
|
|
73
|
+
function registerProtectedAppRoutes(app, deps) {
|
|
74
|
+
void deps;
|
|
75
|
+
app.get('/api/ai-hub/bootstrap', (req, res) => {
|
|
76
|
+
const apiKeyData = req.apiKeyData;
|
|
77
|
+
if (!apiKeyData?.userId) {
|
|
78
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
79
|
+
}
|
|
80
|
+
const project = hostedProjectSummary();
|
|
81
|
+
const projectName = path_1.default.basename(project.path) || 'FRAIM';
|
|
82
|
+
const projectEntry = {
|
|
83
|
+
id: 'hosted-fraim',
|
|
84
|
+
name: projectName,
|
|
85
|
+
folderPath: project.path,
|
|
86
|
+
intent: '',
|
|
87
|
+
outcome: '',
|
|
88
|
+
rules: '',
|
|
89
|
+
brief: '',
|
|
90
|
+
};
|
|
91
|
+
return res.json({
|
|
92
|
+
title: 'AI Hub',
|
|
93
|
+
remoteBaseUrl: requestBaseUrl(req),
|
|
94
|
+
project,
|
|
95
|
+
preferences: {
|
|
96
|
+
projectPath: project.path,
|
|
97
|
+
employeeId: 'claude',
|
|
98
|
+
categoryId: 'hosted',
|
|
99
|
+
recentJobIds: [],
|
|
100
|
+
recentJobInstructions: {},
|
|
101
|
+
personaKey: null,
|
|
102
|
+
projects: [projectEntry],
|
|
103
|
+
},
|
|
104
|
+
categories: [],
|
|
105
|
+
jobs: [],
|
|
106
|
+
managerTemplates: [],
|
|
107
|
+
employees: [
|
|
108
|
+
{ id: 'claude', label: 'Claude', available: true, detail: 'Hosted' },
|
|
109
|
+
],
|
|
110
|
+
personas: [],
|
|
111
|
+
subscriptionActive: false,
|
|
112
|
+
projects: [projectEntry],
|
|
113
|
+
firstRun: { install: true, company: true, hire: true, project: true },
|
|
114
|
+
teamContext: hostedTeamContext(),
|
|
115
|
+
brain: {
|
|
116
|
+
learnings: { organization: 0, manager: 0, project: 0, rawSignals: 0 },
|
|
117
|
+
catalog: { jobs: 0, skills: 0, rules: 0 },
|
|
118
|
+
},
|
|
119
|
+
userEmail: apiKeyData.userId,
|
|
120
|
+
assignments: { byProject: {}, source: 'client-localStorage' },
|
|
121
|
+
managerHiring: { qualities: [], services: [], roles: {} },
|
|
122
|
+
managerTeam: [],
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -238,7 +238,11 @@ function registerPublicAuthRoutes(app, deps) {
|
|
|
238
238
|
if (surface === 'mobile') {
|
|
239
239
|
return res.status(200).json({ ok: true, mobileCode: issueMobileExchangeCode(apiKeyData.key) });
|
|
240
240
|
}
|
|
241
|
-
|
|
241
|
+
// The account UI can reveal the user's API key immediately after a
|
|
242
|
+
// valid session is established. Return it here as well so embedded
|
|
243
|
+
// Hub auth can hand the key to its parent without depending on
|
|
244
|
+
// third-party iframe cookies.
|
|
245
|
+
return res.status(200).json({ ok: true, redirectTo: requestedRedirect, apiKey: apiKeyData.key });
|
|
242
246
|
}
|
|
243
247
|
catch (err) {
|
|
244
248
|
console.error('[FRAIM AUTH] /api/auth/email/verify-code error:', err instanceof Error ? err.message : String(err));
|
|
@@ -61,6 +61,38 @@ function buildBaseUrl(req) {
|
|
|
61
61
|
function callbackUrl(req, provider) {
|
|
62
62
|
return `${buildBaseUrl(req)}/api/auth/oauth/${provider}/callback`;
|
|
63
63
|
}
|
|
64
|
+
function isLoopbackHost(hostname) {
|
|
65
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
66
|
+
}
|
|
67
|
+
function safeLoopbackHubReturn(input) {
|
|
68
|
+
if (typeof input !== 'string' || !input)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (input.includes('\n') || input.includes('\r'))
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(input);
|
|
74
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !isLoopbackHost(url.hostname))
|
|
75
|
+
return undefined;
|
|
76
|
+
if (url.username || url.password)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (!url.pathname.startsWith('/ai-hub/'))
|
|
79
|
+
return undefined;
|
|
80
|
+
return url.toString();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function buildHubReturnRedirect(hubReturn, surface, apiKey) {
|
|
87
|
+
if (!hubReturn || !apiKey)
|
|
88
|
+
return null;
|
|
89
|
+
const target = new URL(hubReturn);
|
|
90
|
+
if (!target.searchParams.get('connected') && surface !== 'mobile') {
|
|
91
|
+
target.searchParams.set('connected', surface);
|
|
92
|
+
}
|
|
93
|
+
target.searchParams.set('api-key', apiKey);
|
|
94
|
+
return target.toString();
|
|
95
|
+
}
|
|
64
96
|
function decodeJwtPayload(jwt) {
|
|
65
97
|
const parts = jwt.split('.');
|
|
66
98
|
if (parts.length !== 3)
|
|
@@ -86,9 +118,10 @@ function registerOAuthRoutes(app, deps) {
|
|
|
86
118
|
});
|
|
87
119
|
// Helper used by both providers to finalise authentication once we have a verified email.
|
|
88
120
|
async function completeSignIn(req, res, params) {
|
|
89
|
-
const { verifiedEmail, provider, surface, redirectTo, intent } = params;
|
|
121
|
+
const { verifiedEmail, provider, surface, redirectTo, intent, hubReturn } = params;
|
|
90
122
|
const lower = verifiedEmail.toLowerCase();
|
|
91
123
|
const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
|
|
124
|
+
let apiKeyForHubReturn = apiKeyData?.key ?? '';
|
|
92
125
|
if (!apiKeyData && intent !== 'signup') {
|
|
93
126
|
// Sign-in is NOT a sign-up. If the verified email doesn't already match a
|
|
94
127
|
// FRAIM account, route the user to the sign-up flow. Account creation is
|
|
@@ -107,7 +140,7 @@ function registerOAuthRoutes(app, deps) {
|
|
|
107
140
|
return res.redirect(`/auth/error.html?reason=no_account`);
|
|
108
141
|
}
|
|
109
142
|
if (!apiKeyData && intent === 'signup') {
|
|
110
|
-
await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
|
|
143
|
+
apiKeyForHubReturn = await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
|
|
111
144
|
}
|
|
112
145
|
const sessionId = (0, oauth_helpers_1.generateSessionId)();
|
|
113
146
|
await dbService.createAuthSession({
|
|
@@ -121,6 +154,10 @@ function registerOAuthRoutes(app, deps) {
|
|
|
121
154
|
await (0, audit_log_1.auditLog)('SESSION_CREATED', { userId: lower, sessionId, surface, provider, outcome: 'success' });
|
|
122
155
|
(0, cookie_service_1.setSessionCookie)(res, sessionId);
|
|
123
156
|
(0, cookie_service_1.clearOAuthPendingCookie)(res);
|
|
157
|
+
const hubRedirect = buildHubReturnRedirect(hubReturn, surface, apiKeyForHubReturn);
|
|
158
|
+
if (hubRedirect) {
|
|
159
|
+
return res.redirect(hubRedirect);
|
|
160
|
+
}
|
|
124
161
|
res.redirect(redirectTo);
|
|
125
162
|
}
|
|
126
163
|
app.get('/api/auth/oauth/:provider/start', (req, res, next) => {
|
|
@@ -136,6 +173,7 @@ function registerOAuthRoutes(app, deps) {
|
|
|
136
173
|
const surface = (0, oauth_helpers_1.pickSurfaceOrDefault)(req.query.surface);
|
|
137
174
|
const intent = pickIntent(req.query.intent);
|
|
138
175
|
const redirectTo = (0, oauth_helpers_1.safeRedirectPath)(req.query.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
|
|
176
|
+
const hubReturn = safeLoopbackHubReturn(req.query.hub_return);
|
|
139
177
|
try {
|
|
140
178
|
const codeVerifier = (0, oauth_helpers_1.generatePkceVerifier)();
|
|
141
179
|
const stateNonce = (0, oauth_helpers_1.generateStateNonce)();
|
|
@@ -148,6 +186,7 @@ function registerOAuthRoutes(app, deps) {
|
|
|
148
186
|
codeVerifier,
|
|
149
187
|
stateNonce,
|
|
150
188
|
redirectTo,
|
|
189
|
+
hubReturn,
|
|
151
190
|
});
|
|
152
191
|
(0, cookie_service_1.setOAuthPendingCookie)(res, pendingId);
|
|
153
192
|
await (0, audit_log_1.auditLog)('OAUTH_START', { provider, surface, ip: (0, oauth_helpers_1.clientIpFromReq)(req), outcome: 'success' });
|
|
@@ -257,6 +296,7 @@ function registerOAuthRoutes(app, deps) {
|
|
|
257
296
|
surface: pending.surface,
|
|
258
297
|
redirectTo: pending.redirectTo,
|
|
259
298
|
intent: pickIntent(pending.intent),
|
|
299
|
+
hubReturn: pending.hubReturn,
|
|
260
300
|
});
|
|
261
301
|
}
|
|
262
302
|
catch (err) {
|
|
@@ -350,6 +390,7 @@ function registerOAuthRoutes(app, deps) {
|
|
|
350
390
|
surface: pending.surface,
|
|
351
391
|
redirectTo: pending.redirectTo,
|
|
352
392
|
intent: pickIntent(pending.intent),
|
|
393
|
+
hubReturn: pending.hubReturn,
|
|
353
394
|
});
|
|
354
395
|
}
|
|
355
396
|
catch (err) {
|
package/package.json
CHANGED
package/public/ai-hub/script.js
CHANGED
|
@@ -45,11 +45,13 @@ const state = {
|
|
|
45
45
|
selectedJob: null, // chosen in modal step 1
|
|
46
46
|
selectedEmployeeId: null,
|
|
47
47
|
selectedPersonaKey: null, // R4: null = "All employees"
|
|
48
|
-
modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
|
|
49
|
-
storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
|
|
50
|
-
remoteBaseUrl: null,
|
|
51
|
-
connectedReturnArea: 'projects',
|
|
52
|
-
|
|
48
|
+
modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
|
|
49
|
+
storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
|
|
50
|
+
remoteBaseUrl: null,
|
|
51
|
+
connectedReturnArea: 'projects',
|
|
52
|
+
connectedSurface: null,
|
|
53
|
+
pendingConnectedSurfaceAfterAuth: null,
|
|
54
|
+
panelState: {}, // { [convId]: { coach?: boolean } }
|
|
53
55
|
wordContext: null, // WordContext pushed from taskpane.html via postMessage
|
|
54
56
|
// Pending coaching job selected via a quick-coach button or template picker.
|
|
55
57
|
// Sent as coachingJobId in the next continueRun call and then cleared.
|
|
@@ -146,6 +148,34 @@ async function requestJson(url, options) {
|
|
|
146
148
|
return payload;
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
function tfIsLoopbackHost(hostname) {
|
|
152
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function tfIsLoopbackOrigin(origin) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = new URL(origin);
|
|
158
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && tfIsLoopbackHost(parsed.hostname);
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function tfConsumeReturnedApiKeyFromUrl() {
|
|
165
|
+
try {
|
|
166
|
+
if (!tfIsLoopbackHost(window.location.hostname)) return null;
|
|
167
|
+
const url = new URL(window.location.href);
|
|
168
|
+
const apiKey = url.searchParams.get('api-key') || url.searchParams.get('apiKey');
|
|
169
|
+
if (!apiKey) return null;
|
|
170
|
+
url.searchParams.delete('api-key');
|
|
171
|
+
url.searchParams.delete('apiKey');
|
|
172
|
+
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
|
|
173
|
+
return apiKey;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
149
179
|
function bootstrapCacheKey(projectPath, docUrl) {
|
|
150
180
|
return `${tfCanonicalProjectPath(projectPath || '')}::${docUrl || ''}`;
|
|
151
181
|
}
|
|
@@ -155,14 +185,14 @@ function cacheBootstrapPayload(bootstrap, docUrl) {
|
|
|
155
185
|
bootstrapCache.set(bootstrapCacheKey(bootstrap.project.path, docUrl), bootstrap);
|
|
156
186
|
}
|
|
157
187
|
|
|
158
|
-
function applyBootstrap(bootstrap, docUrl) {
|
|
159
|
-
state.bootstrap = bootstrap;
|
|
160
|
-
state.projectPath = bootstrap.project.path;
|
|
161
|
-
state.remoteBaseUrl = bootstrap.remoteBaseUrl || null;
|
|
162
|
-
if (bootstrap.preferences && bootstrap.preferences.apiKey) {
|
|
163
|
-
state.storedApiKey = bootstrap.preferences.apiKey;
|
|
164
|
-
}
|
|
165
|
-
state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
|
|
188
|
+
function applyBootstrap(bootstrap, docUrl) {
|
|
189
|
+
state.bootstrap = bootstrap;
|
|
190
|
+
state.projectPath = bootstrap.project.path;
|
|
191
|
+
state.remoteBaseUrl = bootstrap.remoteBaseUrl || null;
|
|
192
|
+
if (bootstrap.preferences && bootstrap.preferences.apiKey) {
|
|
193
|
+
state.storedApiKey = bootstrap.preferences.apiKey;
|
|
194
|
+
}
|
|
195
|
+
state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
|
|
166
196
|
// Restore persona selection persisted on the server side.
|
|
167
197
|
if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
|
|
168
198
|
state.selectedPersonaKey = bootstrap.preferences.personaKey;
|
|
@@ -235,6 +265,20 @@ async function loadBootstrap(projectPath, docUrl, options) {
|
|
|
235
265
|
return bootstrap;
|
|
236
266
|
}
|
|
237
267
|
|
|
268
|
+
function hostedHubNeedsSignIn(error) {
|
|
269
|
+
if (!error || error.status !== 401) return false;
|
|
270
|
+
const host = window.location.hostname;
|
|
271
|
+
return host !== 'localhost' && host !== '127.0.0.1';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function redirectHostedHubToSignIn() {
|
|
275
|
+
const redirectTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/ai-hub/';
|
|
276
|
+
const target = new URL('/auth/sign-in.html', window.location.origin);
|
|
277
|
+
target.searchParams.set('surface', 'account');
|
|
278
|
+
target.searchParams.set('redirect_to', redirectTo);
|
|
279
|
+
window.location.href = target.toString();
|
|
280
|
+
}
|
|
281
|
+
|
|
238
282
|
// ---------------------------------------------------------------------------
|
|
239
283
|
// Word context bridge (postMessage from taskpane.html parent frame)
|
|
240
284
|
// ---------------------------------------------------------------------------
|
|
@@ -5917,6 +5961,11 @@ function wireEvents() {
|
|
|
5917
5961
|
const isFirstRun = urlParams.get('firstRun') === 'true';
|
|
5918
5962
|
const surface = urlParams.get('surface') || 'hub';
|
|
5919
5963
|
const docUrl = urlParams.get('docUrl') || '';
|
|
5964
|
+
const requestedConnectedSurface = tfRequestedConnectedSurface();
|
|
5965
|
+
const returnedApiKey = tfConsumeReturnedApiKeyFromUrl();
|
|
5966
|
+
if (returnedApiKey && requestedConnectedSurface) {
|
|
5967
|
+
state.pendingConnectedSurfaceAfterAuth = requestedConnectedSurface;
|
|
5968
|
+
}
|
|
5920
5969
|
|
|
5921
5970
|
// Apply surface class before bootstrap so CSS takes effect immediately.
|
|
5922
5971
|
if (surface !== 'hub') document.body.dataset.surface = surface;
|
|
@@ -5926,9 +5975,17 @@ function wireEvents() {
|
|
|
5926
5975
|
|
|
5927
5976
|
try {
|
|
5928
5977
|
await loadBootstrap(null, docUrl);
|
|
5978
|
+
if (returnedApiKey) {
|
|
5979
|
+
await tfRememberConnectedApiKey(returnedApiKey);
|
|
5980
|
+
tfOpenRequestedConnectedSurface();
|
|
5981
|
+
}
|
|
5929
5982
|
await hydrateConversationsFromServer();
|
|
5930
5983
|
startBgConvPoll();
|
|
5931
5984
|
} catch (error) {
|
|
5985
|
+
if (hostedHubNeedsSignIn(error)) {
|
|
5986
|
+
redirectHostedHubToSignIn();
|
|
5987
|
+
return;
|
|
5988
|
+
}
|
|
5932
5989
|
showStatus(error.message, true);
|
|
5933
5990
|
return;
|
|
5934
5991
|
}
|
|
@@ -6211,7 +6268,7 @@ const STORAGE_KEY_ASSIGNMENTS_512 = 'fraim.aiHub.assignments.v1';
|
|
|
6211
6268
|
|
|
6212
6269
|
const tf = {
|
|
6213
6270
|
enabled: false,
|
|
6214
|
-
area: 'projects', // company | manager | projects | brain | connected
|
|
6271
|
+
area: 'projects', // company | manager | projects | brain | connected
|
|
6215
6272
|
projectView: 'overview', // overview | workspace
|
|
6216
6273
|
activeProjectId: null,
|
|
6217
6274
|
projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
|
|
@@ -9242,107 +9299,148 @@ function tfToggleAccountMenu(e) {
|
|
|
9242
9299
|
const menu = document.getElementById('account-menu');
|
|
9243
9300
|
if (menu) menu.classList.toggle('open');
|
|
9244
9301
|
}
|
|
9245
|
-
function tfCloseAccountMenu() {
|
|
9246
|
-
const menu = document.getElementById('account-menu');
|
|
9247
|
-
if (menu) menu.classList.remove('open');
|
|
9248
|
-
}
|
|
9249
|
-
|
|
9250
|
-
const CONNECTED_SURFACES = {
|
|
9251
|
-
account: { title: 'Account', path: '/account/' },
|
|
9252
|
-
analytics: { title: 'Analytics', path: '/analytics/' },
|
|
9253
|
-
brain: { title: 'Brain', path: '/fraim-brain.html', redirectPath: '/fraim-brain' },
|
|
9254
|
-
};
|
|
9255
|
-
|
|
9256
|
-
function tfConnectedRemoteBase() {
|
|
9257
|
-
const fallback = window.location.origin;
|
|
9258
|
-
const raw = state.remoteBaseUrl || (state.bootstrap && state.bootstrap.remoteBaseUrl) || fallback;
|
|
9259
|
-
try {
|
|
9260
|
-
return new URL(raw, fallback).toString();
|
|
9261
|
-
} catch {
|
|
9262
|
-
return fallback;
|
|
9263
|
-
}
|
|
9264
|
-
}
|
|
9265
|
-
|
|
9266
|
-
function tfConnectedApiKey() {
|
|
9267
|
-
return state.storedApiKey || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.apiKey) || '';
|
|
9268
|
-
}
|
|
9269
|
-
|
|
9270
|
-
function
|
|
9271
|
-
|
|
9272
|
-
|
|
9273
|
-
|
|
9274
|
-
|
|
9275
|
-
|
|
9276
|
-
|
|
9277
|
-
|
|
9278
|
-
|
|
9279
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
9288
|
-
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
const
|
|
9292
|
-
const
|
|
9293
|
-
|
|
9294
|
-
const
|
|
9295
|
-
|
|
9296
|
-
|
|
9297
|
-
|
|
9298
|
-
|
|
9299
|
-
}
|
|
9300
|
-
if (
|
|
9301
|
-
|
|
9302
|
-
|
|
9303
|
-
|
|
9304
|
-
|
|
9305
|
-
|
|
9306
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
const
|
|
9324
|
-
if (
|
|
9325
|
-
if (
|
|
9326
|
-
|
|
9327
|
-
|
|
9328
|
-
|
|
9329
|
-
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
9336
|
-
|
|
9337
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
9340
|
-
|
|
9341
|
-
|
|
9342
|
-
|
|
9302
|
+
function tfCloseAccountMenu() {
|
|
9303
|
+
const menu = document.getElementById('account-menu');
|
|
9304
|
+
if (menu) menu.classList.remove('open');
|
|
9305
|
+
}
|
|
9306
|
+
|
|
9307
|
+
const CONNECTED_SURFACES = {
|
|
9308
|
+
account: { title: 'Account', path: '/account/' },
|
|
9309
|
+
analytics: { title: 'Analytics', path: '/analytics/' },
|
|
9310
|
+
brain: { title: 'Brain', path: '/fraim-brain.html', redirectPath: '/fraim-brain' },
|
|
9311
|
+
};
|
|
9312
|
+
|
|
9313
|
+
function tfConnectedRemoteBase() {
|
|
9314
|
+
const fallback = window.location.origin;
|
|
9315
|
+
const raw = state.remoteBaseUrl || (state.bootstrap && state.bootstrap.remoteBaseUrl) || fallback;
|
|
9316
|
+
try {
|
|
9317
|
+
return new URL(raw, fallback).toString();
|
|
9318
|
+
} catch {
|
|
9319
|
+
return fallback;
|
|
9320
|
+
}
|
|
9321
|
+
}
|
|
9322
|
+
|
|
9323
|
+
function tfConnectedApiKey() {
|
|
9324
|
+
return state.storedApiKey || (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.apiKey) || '';
|
|
9325
|
+
}
|
|
9326
|
+
|
|
9327
|
+
function tfConnectedRedirectPath(surface, cfg, remoteBase) {
|
|
9328
|
+
try {
|
|
9329
|
+
if (new URL(remoteBase).origin === window.location.origin) {
|
|
9330
|
+
return `/ai-hub/?connected=${encodeURIComponent(surface)}`;
|
|
9331
|
+
}
|
|
9332
|
+
} catch {}
|
|
9333
|
+
return cfg.redirectPath || cfg.path;
|
|
9334
|
+
}
|
|
9335
|
+
|
|
9336
|
+
function tfConnectedHubReturnUrl(surface) {
|
|
9337
|
+
if (!tfIsLoopbackOrigin(window.location.origin)) return '';
|
|
9338
|
+
try {
|
|
9339
|
+
const target = new URL('/ai-hub/', window.location.origin);
|
|
9340
|
+
target.searchParams.set('connected', surface);
|
|
9341
|
+
return target.toString();
|
|
9342
|
+
} catch {
|
|
9343
|
+
return '';
|
|
9344
|
+
}
|
|
9345
|
+
}
|
|
9346
|
+
|
|
9347
|
+
function tfConnectedSurfaceUrl(surface) {
|
|
9348
|
+
const cfg = CONNECTED_SURFACES[surface];
|
|
9349
|
+
const remoteBase = tfConnectedRemoteBase();
|
|
9350
|
+
if (!cfg) return remoteBase;
|
|
9351
|
+
const apiKey = tfConnectedApiKey();
|
|
9352
|
+
try {
|
|
9353
|
+
if (new URL(remoteBase).origin === window.location.origin) {
|
|
9354
|
+
return new URL(cfg.path, remoteBase).toString();
|
|
9355
|
+
}
|
|
9356
|
+
} catch {}
|
|
9357
|
+
if (apiKey) {
|
|
9358
|
+
const target = new URL(cfg.path, remoteBase);
|
|
9359
|
+
target.searchParams.set('api-key', apiKey);
|
|
9360
|
+
return target.toString();
|
|
9361
|
+
}
|
|
9362
|
+
const signIn = new URL('/auth/sign-in.html', remoteBase);
|
|
9363
|
+
signIn.searchParams.set('surface', surface);
|
|
9364
|
+
signIn.searchParams.set('embedded', '1');
|
|
9365
|
+
signIn.searchParams.set('redirect_to', tfConnectedRedirectPath(surface, cfg, remoteBase));
|
|
9366
|
+
const hubReturn = tfConnectedHubReturnUrl(surface);
|
|
9367
|
+
if (hubReturn) signIn.searchParams.set('hub_return', hubReturn);
|
|
9368
|
+
return signIn.toString();
|
|
9369
|
+
}
|
|
9370
|
+
|
|
9371
|
+
function tfOpenConnectedSurface(surface) {
|
|
9372
|
+
const cfg = CONNECTED_SURFACES[surface];
|
|
9373
|
+
if (!cfg) return;
|
|
9374
|
+
tfCloseAccountMenu();
|
|
9375
|
+
state.connectedReturnArea = (tf.area && tf.area !== 'connected') ? tf.area : 'projects';
|
|
9376
|
+
state.connectedSurface = surface;
|
|
9377
|
+
const url = tfConnectedSurfaceUrl(surface);
|
|
9378
|
+
const title = document.getElementById('connected-title');
|
|
9379
|
+
const origin = document.getElementById('connected-origin');
|
|
9380
|
+
const frame = document.getElementById('connected-frame');
|
|
9381
|
+
if (title) title.textContent = cfg.title;
|
|
9382
|
+
if (origin) {
|
|
9383
|
+
try { origin.textContent = new URL(url).origin; }
|
|
9384
|
+
catch { origin.textContent = ''; }
|
|
9385
|
+
}
|
|
9386
|
+
if (frame) {
|
|
9387
|
+
frame.title = `FRAIM ${cfg.title}`;
|
|
9388
|
+
frame.src = url;
|
|
9389
|
+
}
|
|
9390
|
+
tfShowArea('connected');
|
|
9391
|
+
}
|
|
9392
|
+
|
|
9393
|
+
function tfCloseConnectedSurface() {
|
|
9394
|
+
const frame = document.getElementById('connected-frame');
|
|
9395
|
+
if (frame) frame.removeAttribute('src');
|
|
9396
|
+
state.connectedSurface = null;
|
|
9397
|
+
const returnArea = state.connectedReturnArea && state.connectedReturnArea !== 'connected'
|
|
9398
|
+
? state.connectedReturnArea
|
|
9399
|
+
: 'projects';
|
|
9400
|
+
tfShowArea(returnArea);
|
|
9401
|
+
}
|
|
9402
|
+
|
|
9403
|
+
async function tfHandleConnectedAuthMessage(event) {
|
|
9404
|
+
const frame = document.getElementById('connected-frame');
|
|
9405
|
+
if (frame && frame.contentWindow && event.source !== frame.contentWindow) return;
|
|
9406
|
+
let remoteOrigin = '';
|
|
9407
|
+
try { remoteOrigin = new URL(tfConnectedRemoteBase()).origin; }
|
|
9408
|
+
catch { return; }
|
|
9409
|
+
if (event.origin !== remoteOrigin) return;
|
|
9410
|
+
const msg = event.data || {};
|
|
9411
|
+
if (!msg || msg.type !== 'fraim-auth-api-key' || typeof msg.apiKey !== 'string' || !msg.apiKey) return;
|
|
9412
|
+
await tfRememberConnectedApiKey(msg.apiKey);
|
|
9413
|
+
if (state.connectedSurface) {
|
|
9414
|
+
const nextUrl = tfConnectedSurfaceUrl(state.connectedSurface);
|
|
9415
|
+
const frameEl = document.getElementById('connected-frame');
|
|
9416
|
+
if (frameEl) frameEl.src = nextUrl;
|
|
9417
|
+
}
|
|
9418
|
+
}
|
|
9419
|
+
|
|
9420
|
+
async function tfRememberConnectedApiKey(apiKey) {
|
|
9421
|
+
const changed = apiKey !== state.storedApiKey;
|
|
9422
|
+
state.storedApiKey = apiKey;
|
|
9423
|
+
if (state.bootstrap && state.bootstrap.preferences) {
|
|
9424
|
+
state.bootstrap.preferences.apiKey = apiKey;
|
|
9425
|
+
}
|
|
9426
|
+
if (changed) {
|
|
9427
|
+
try {
|
|
9428
|
+
await requestJson('/api/ai-hub/api-key', {
|
|
9429
|
+
method: 'POST',
|
|
9430
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9431
|
+
body: JSON.stringify({ apiKey }),
|
|
9432
|
+
});
|
|
9433
|
+
} catch (error) {
|
|
9434
|
+
console.warn('[ai-hub] Could not persist connected auth key:', error);
|
|
9435
|
+
}
|
|
9436
|
+
}
|
|
9437
|
+
}
|
|
9438
|
+
|
|
9439
|
+
// ---------------------------------------------------------------------------
|
|
9440
|
+
// Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
|
|
9343
9441
|
// sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
|
|
9344
9442
|
// OS preference; here we reflect it in the toggle control and persist changes.
|
|
9345
|
-
// Embedded connected surfaces receive live theme updates via postMessage.
|
|
9443
|
+
// Embedded connected surfaces receive live theme updates via postMessage.
|
|
9346
9444
|
// ---------------------------------------------------------------------------
|
|
9347
9445
|
function tfCurrentTheme() {
|
|
9348
9446
|
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
|
@@ -9362,18 +9460,18 @@ function tfApplyTheme(theme, persist) {
|
|
|
9362
9460
|
document.documentElement.setAttribute('data-theme', t);
|
|
9363
9461
|
if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
|
|
9364
9462
|
tfSyncThemeToggle(t);
|
|
9365
|
-
// Keep embedded surfaces in sync live, including the connected main-server frame.
|
|
9366
|
-
try {
|
|
9367
|
-
document.querySelectorAll('iframe').forEach((f) => {
|
|
9368
|
-
if (!f.contentWindow) return;
|
|
9369
|
-
let targetOrigin = location.origin;
|
|
9370
|
-
try {
|
|
9371
|
-
if (f.src) targetOrigin = new URL(f.src, location.href).origin;
|
|
9372
|
-
} catch (e) {}
|
|
9373
|
-
f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, targetOrigin);
|
|
9374
|
-
});
|
|
9375
|
-
} catch (e) {}
|
|
9376
|
-
}
|
|
9463
|
+
// Keep embedded surfaces in sync live, including the connected main-server frame.
|
|
9464
|
+
try {
|
|
9465
|
+
document.querySelectorAll('iframe').forEach((f) => {
|
|
9466
|
+
if (!f.contentWindow) return;
|
|
9467
|
+
let targetOrigin = location.origin;
|
|
9468
|
+
try {
|
|
9469
|
+
if (f.src) targetOrigin = new URL(f.src, location.href).origin;
|
|
9470
|
+
} catch (e) {}
|
|
9471
|
+
f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, targetOrigin);
|
|
9472
|
+
});
|
|
9473
|
+
} catch (e) {}
|
|
9474
|
+
}
|
|
9377
9475
|
function tfToggleTheme(e) {
|
|
9378
9476
|
if (e) e.stopPropagation();
|
|
9379
9477
|
tfApplyTheme(tfCurrentTheme() === 'dark' ? 'light' : 'dark', true);
|
|
@@ -10099,31 +10197,31 @@ function tfWireShell() {
|
|
|
10099
10197
|
for (const tab of document.querySelectorAll('.hub-tab')) {
|
|
10100
10198
|
tab.addEventListener('click', () => tfShowArea(tab.dataset.area));
|
|
10101
10199
|
}
|
|
10102
|
-
const avatar = document.getElementById('avatar-btn');
|
|
10103
|
-
if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
|
|
10104
|
-
tfWireThemeToggle();
|
|
10105
|
-
const accountItem = document.getElementById('am-account');
|
|
10106
|
-
if (accountItem) accountItem.addEventListener('click', (e) => {
|
|
10107
|
-
e.preventDefault();
|
|
10108
|
-
tfOpenConnectedSurface('account');
|
|
10109
|
-
});
|
|
10110
|
-
const brainItem = document.getElementById('am-brain');
|
|
10111
|
-
if (brainItem) brainItem.addEventListener('click', () => {
|
|
10112
|
-
tfOpenConnectedSurface('brain');
|
|
10113
|
-
});
|
|
10114
|
-
const analyticsItem = document.getElementById('am-analytics');
|
|
10115
|
-
if (analyticsItem) analyticsItem.addEventListener('click', (e) => {
|
|
10116
|
-
e.preventDefault();
|
|
10117
|
-
tfOpenConnectedSurface('analytics');
|
|
10118
|
-
});
|
|
10119
|
-
const connectedClose = document.getElementById('connected-close');
|
|
10120
|
-
if (connectedClose) connectedClose.addEventListener('click', tfCloseConnectedSurface);
|
|
10121
|
-
if (!tf.connectedAuthMessageWired) {
|
|
10122
|
-
window.addEventListener('message', tfHandleConnectedAuthMessage);
|
|
10123
|
-
tf.connectedAuthMessageWired = true;
|
|
10124
|
-
}
|
|
10125
|
-
const signout = document.getElementById('am-signout');
|
|
10126
|
-
if (signout) signout.addEventListener('click', () => { window.location.href = '/auth/sign-in.html'; });
|
|
10200
|
+
const avatar = document.getElementById('avatar-btn');
|
|
10201
|
+
if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
|
|
10202
|
+
tfWireThemeToggle();
|
|
10203
|
+
const accountItem = document.getElementById('am-account');
|
|
10204
|
+
if (accountItem) accountItem.addEventListener('click', (e) => {
|
|
10205
|
+
e.preventDefault();
|
|
10206
|
+
tfOpenConnectedSurface('account');
|
|
10207
|
+
});
|
|
10208
|
+
const brainItem = document.getElementById('am-brain');
|
|
10209
|
+
if (brainItem) brainItem.addEventListener('click', () => {
|
|
10210
|
+
tfOpenConnectedSurface('brain');
|
|
10211
|
+
});
|
|
10212
|
+
const analyticsItem = document.getElementById('am-analytics');
|
|
10213
|
+
if (analyticsItem) analyticsItem.addEventListener('click', (e) => {
|
|
10214
|
+
e.preventDefault();
|
|
10215
|
+
tfOpenConnectedSurface('analytics');
|
|
10216
|
+
});
|
|
10217
|
+
const connectedClose = document.getElementById('connected-close');
|
|
10218
|
+
if (connectedClose) connectedClose.addEventListener('click', tfCloseConnectedSurface);
|
|
10219
|
+
if (!tf.connectedAuthMessageWired) {
|
|
10220
|
+
window.addEventListener('message', tfHandleConnectedAuthMessage);
|
|
10221
|
+
tf.connectedAuthMessageWired = true;
|
|
10222
|
+
}
|
|
10223
|
+
const signout = document.getElementById('am-signout');
|
|
10224
|
+
if (signout) signout.addEventListener('click', () => { window.location.href = '/auth/sign-in.html'; });
|
|
10127
10225
|
document.addEventListener('click', (e) => {
|
|
10128
10226
|
const menu = document.getElementById('account-menu');
|
|
10129
10227
|
if (menu && menu.classList.contains('open') && !(e.target.closest && e.target.closest('.nav-right'))) {
|
|
@@ -10231,15 +10329,15 @@ function tfWireShell() {
|
|
|
10231
10329
|
['assign-job-modal', tfCloseAssignJob],
|
|
10232
10330
|
['np-modal', tfCloseNewProject],
|
|
10233
10331
|
];
|
|
10234
|
-
document.addEventListener('keydown', (e) => {
|
|
10235
|
-
if (e.key !== 'Escape') return;
|
|
10236
|
-
if (tf.area === 'connected') {
|
|
10237
|
-
e.stopPropagation();
|
|
10238
|
-
tfCloseConnectedSurface();
|
|
10239
|
-
return;
|
|
10240
|
-
}
|
|
10241
|
-
for (const [id, fn] of escClosers) {
|
|
10242
|
-
const el = document.getElementById(id);
|
|
10332
|
+
document.addEventListener('keydown', (e) => {
|
|
10333
|
+
if (e.key !== 'Escape') return;
|
|
10334
|
+
if (tf.area === 'connected') {
|
|
10335
|
+
e.stopPropagation();
|
|
10336
|
+
tfCloseConnectedSurface();
|
|
10337
|
+
return;
|
|
10338
|
+
}
|
|
10339
|
+
for (const [id, fn] of escClosers) {
|
|
10340
|
+
const el = document.getElementById(id);
|
|
10243
10341
|
if (el && !el.hidden) { e.stopPropagation(); fn(); return; }
|
|
10244
10342
|
}
|
|
10245
10343
|
});
|
|
@@ -10274,6 +10372,36 @@ function tfPopulateAccountMenu() {
|
|
|
10274
10372
|
if (avatarBtn) avatarBtn.textContent = id.initials;
|
|
10275
10373
|
}
|
|
10276
10374
|
|
|
10375
|
+
function tfRequestedConnectedSurface() {
|
|
10376
|
+
try {
|
|
10377
|
+
const params = new URLSearchParams(window.location.search);
|
|
10378
|
+
const surface = params.get('connected') || params.get('open');
|
|
10379
|
+
return CONNECTED_SURFACES[surface] ? surface : null;
|
|
10380
|
+
} catch {
|
|
10381
|
+
return null;
|
|
10382
|
+
}
|
|
10383
|
+
}
|
|
10384
|
+
|
|
10385
|
+
function tfClearConnectedSurfaceRequest() {
|
|
10386
|
+
try {
|
|
10387
|
+
const url = new URL(window.location.href);
|
|
10388
|
+
url.searchParams.delete('connected');
|
|
10389
|
+
url.searchParams.delete('open');
|
|
10390
|
+
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
|
|
10391
|
+
} catch {}
|
|
10392
|
+
}
|
|
10393
|
+
|
|
10394
|
+
function tfOpenRequestedConnectedSurface() {
|
|
10395
|
+
if (!tf.enabled) return;
|
|
10396
|
+
const pendingAfterAuth = state.pendingConnectedSurfaceAfterAuth;
|
|
10397
|
+
if (pendingAfterAuth && !tfConnectedApiKey()) return;
|
|
10398
|
+
const surface = pendingAfterAuth || tfRequestedConnectedSurface();
|
|
10399
|
+
if (!surface) return;
|
|
10400
|
+
state.pendingConnectedSurfaceAfterAuth = null;
|
|
10401
|
+
tfClearConnectedSurfaceRequest();
|
|
10402
|
+
setTimeout(() => tfOpenConnectedSurface(surface), 0);
|
|
10403
|
+
}
|
|
10404
|
+
|
|
10277
10405
|
function tfInitShell() {
|
|
10278
10406
|
if (typeof surface !== 'undefined' && surface !== 'hub') return;
|
|
10279
10407
|
if (document.body.dataset.surface && document.body.dataset.surface !== 'hub') return;
|
|
@@ -10312,6 +10440,7 @@ function tfInitShell() {
|
|
|
10312
10440
|
// the project workspace. Re-render once the workspace is selected so manager
|
|
10313
10441
|
// delegation boards are visible on first load, not only after a refresh tick.
|
|
10314
10442
|
renderActive();
|
|
10443
|
+
tfOpenRequestedConnectedSurface();
|
|
10315
10444
|
}
|
|
10316
10445
|
|
|
10317
10446
|
// Refresh shell-derived UI after a bootstrap reload (e.g. project picked).
|