fraim-framework 2.0.188 → 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/dist/src/ai-hub/desktop-main.js +95 -1
- package/dist/src/ai-hub/server.js +62 -20
- package/dist/src/core/ai-mentor.js +11 -2
- 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/index.html +19 -5
- package/public/ai-hub/script.js +259 -5
- package/public/ai-hub/styles.css +70 -7
|
@@ -10,6 +10,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
10
10
|
const server_1 = require("./server");
|
|
11
11
|
const cert_store_1 = require("./cert-store");
|
|
12
12
|
const office_sideload_1 = require("./office-sideload");
|
|
13
|
+
const remote_hub_gateway_1 = require("./remote-hub-gateway");
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// State
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
@@ -50,6 +51,66 @@ function applyUserDataOverride() {
|
|
|
50
51
|
fs_1.default.mkdirSync(userDataDir, { recursive: true });
|
|
51
52
|
electron_1.app.setPath('userData', userDataDir);
|
|
52
53
|
}
|
|
54
|
+
function isTrustedInAppNavigation(targetUrl, hubUrl) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(targetUrl);
|
|
57
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
58
|
+
return false;
|
|
59
|
+
return parsed.origin === new URL(hubUrl).origin || parsed.origin === new URL((0, remote_hub_gateway_1.resolveFraimRemoteUrl)()).origin;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
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
|
+
}
|
|
53
114
|
// ---------------------------------------------------------------------------
|
|
54
115
|
// Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
|
|
55
116
|
// so the app never crashes if assets aren't present.
|
|
@@ -204,9 +265,42 @@ async function createWindow(url) {
|
|
|
204
265
|
mainWindow.on('closed', () => electron_1.nativeTheme.removeListener('updated', applyOverlay));
|
|
205
266
|
}
|
|
206
267
|
mainWindow.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
|
|
207
|
-
|
|
268
|
+
const wrappedUrl = wrappedHostedNavigationUrl(targetUrl, url);
|
|
269
|
+
if (wrappedUrl) {
|
|
270
|
+
void mainWindow?.loadURL(wrappedUrl);
|
|
271
|
+
return { action: 'deny' };
|
|
272
|
+
}
|
|
273
|
+
if (isTrustedInAppNavigation(targetUrl, url)) {
|
|
274
|
+
void mainWindow?.loadURL(targetUrl);
|
|
275
|
+
}
|
|
208
276
|
return { action: 'deny' };
|
|
209
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
|
+
});
|
|
210
304
|
mainWindow.once('ready-to-show', () => mainWindow?.show());
|
|
211
305
|
// Closing the window hides it to the tray rather than quitting the app.
|
|
212
306
|
// The server keeps running so Word can still reach the task pane.
|
|
@@ -128,6 +128,26 @@ 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 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) {
|
|
139
|
+
const target = new URL('/auth/sign-in.html', (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
|
|
140
|
+
target.searchParams.set('surface', surface);
|
|
141
|
+
target.searchParams.set('redirect_to', redirectTo);
|
|
142
|
+
if (hubReturn)
|
|
143
|
+
target.searchParams.set('hub_return', hubReturn);
|
|
144
|
+
return target.toString();
|
|
145
|
+
}
|
|
146
|
+
function buildHostedPathUrl(pathname, queryString) {
|
|
147
|
+
const target = new URL(pathname, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
|
|
148
|
+
target.search = queryString;
|
|
149
|
+
return target.toString();
|
|
150
|
+
}
|
|
131
151
|
function buildHubPersonaAvatarUrl(personaKey) {
|
|
132
152
|
return loadPersonaHiringModule()?.buildPersonaAvatarUrl(personaKey) ?? '';
|
|
133
153
|
}
|
|
@@ -1407,6 +1427,7 @@ class AiHubServer {
|
|
|
1407
1427
|
const activeRun = latest ? this.enrichRunForResponse(latest) : undefined;
|
|
1408
1428
|
return {
|
|
1409
1429
|
title: 'AI Hub',
|
|
1430
|
+
remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
|
|
1410
1431
|
project,
|
|
1411
1432
|
preferences,
|
|
1412
1433
|
categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
|
|
@@ -1997,26 +2018,47 @@ class AiHubServer {
|
|
|
1997
2018
|
// Issue #512: Serve the account and analytics pages from public/.
|
|
1998
2019
|
// These live outside of /ai-hub so they need explicit routes.
|
|
1999
2020
|
const publicDir = resolveAiHubPublicDir().replace(/[\\/]ai-hub$/, '');
|
|
2000
|
-
this.
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
res.
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
res.
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2021
|
+
if (!this.dbService) {
|
|
2022
|
+
this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
|
|
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
|
+
}
|
|
2031
|
+
res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
|
|
2032
|
+
});
|
|
2033
|
+
this.app.get(['/account', '/account/'], (req, res) => {
|
|
2034
|
+
res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
|
|
2035
|
+
});
|
|
2036
|
+
this.app.get(['/analytics', '/analytics/'], (req, res) => {
|
|
2037
|
+
res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
else {
|
|
2041
|
+
this.app.get('/account', (_req, res) => {
|
|
2042
|
+
const filePath = path_1.default.join(publicDir, 'account', 'index.html');
|
|
2043
|
+
if (fs_1.default.existsSync(filePath)) {
|
|
2044
|
+
res.sendFile(filePath);
|
|
2045
|
+
}
|
|
2046
|
+
else {
|
|
2047
|
+
res.status(404).send('Account page not found.');
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
|
|
2051
|
+
this.app.get('/analytics', (_req, res) => {
|
|
2052
|
+
const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
|
|
2053
|
+
if (fs_1.default.existsSync(filePath)) {
|
|
2054
|
+
res.sendFile(filePath);
|
|
2055
|
+
}
|
|
2056
|
+
else {
|
|
2057
|
+
res.status(404).send('Analytics page not found.');
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
|
|
2061
|
+
}
|
|
2020
2062
|
// Issue #478: Serve the PowerPoint task pane HTML and manifest.
|
|
2021
2063
|
// Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
|
|
2022
2064
|
// request, so we must strip them before resolving the file path. Use a custom
|
|
@@ -96,7 +96,7 @@ class AIMentor {
|
|
|
96
96
|
const metadataPhases = Object.keys(workflow.metadata.phases || {});
|
|
97
97
|
const initialPhase = workflow.metadata.initialPhase || metadataPhases[0] || Array.from(workflow.phases.keys())[0] || 'starting';
|
|
98
98
|
const overview = workflow.overview.trim();
|
|
99
|
-
|
|
99
|
+
const lines = [
|
|
100
100
|
overview,
|
|
101
101
|
'',
|
|
102
102
|
'---',
|
|
@@ -104,7 +104,16 @@ class AIMentor {
|
|
|
104
104
|
'## Start Here',
|
|
105
105
|
`- Initial phase: \`${initialPhase}\``,
|
|
106
106
|
'- Call `seekMentoring` to load the instructions for this phase.'
|
|
107
|
-
]
|
|
107
|
+
];
|
|
108
|
+
// A job that produces reviewable repository changes must resolve its workspace
|
|
109
|
+
// before starting. The mode conditional (branch/worktree vs in place) lives in
|
|
110
|
+
// the skill and is evaluated by the agent from its own local settings.
|
|
111
|
+
if (workflow.metadata.producesRepoChanges === true) {
|
|
112
|
+
lines.push('', '## Set Up Your Workspace First', '- This job changes the repository. Before phase work, read and follow'
|
|
113
|
+
+ ' `get_fraim_file({ path: "skills/engineering/set-up-workspace.md" })`'
|
|
114
|
+
+ ' to work on the issue\'s branch when a repository is configured, or in place otherwise.');
|
|
115
|
+
}
|
|
116
|
+
return lines.join('\n');
|
|
108
117
|
}
|
|
109
118
|
async generateStartingMessage(workflow, phaseId, skipIncludes) {
|
|
110
119
|
const entityType = 'Job';
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.190",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/public/ai-hub/index.html
CHANGED
|
@@ -33,18 +33,18 @@
|
|
|
33
33
|
<div class="am-email" id="am-email"></div>
|
|
34
34
|
</div>
|
|
35
35
|
</div>
|
|
36
|
-
<
|
|
36
|
+
<button class="am-item" id="am-account" type="button">
|
|
37
37
|
<span class="am-ico">👤</span>
|
|
38
38
|
<div><div class="am-label">Account</div><div class="am-sub">API key, sessions, billing</div></div>
|
|
39
|
-
</
|
|
39
|
+
</button>
|
|
40
40
|
<button class="am-item" id="am-brain" type="button">
|
|
41
41
|
<span class="am-ico">🧠</span>
|
|
42
42
|
<div><div class="am-label">Brain</div><div class="am-sub">Everything your team knows</div></div>
|
|
43
43
|
</button>
|
|
44
|
-
<
|
|
44
|
+
<button class="am-item" id="am-analytics" type="button">
|
|
45
45
|
<span class="am-ico">📊</span>
|
|
46
46
|
<div><div class="am-label">Analytics</div><div class="am-sub">Usage, cost, and ROI</div></div>
|
|
47
|
-
</
|
|
47
|
+
</button>
|
|
48
48
|
<button class="am-item am-theme" id="am-theme-toggle" type="button" role="switch" aria-checked="false">
|
|
49
49
|
<span class="am-ico" id="am-theme-ico">🌙</span>
|
|
50
50
|
<div><div class="am-label">Dark mode</div><div class="am-sub" id="am-theme-sub">Off</div></div>
|
|
@@ -141,7 +141,21 @@
|
|
|
141
141
|
</div>
|
|
142
142
|
</section>
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
<section class="hub-area" id="area-connected" hidden>
|
|
145
|
+
<div class="connected-shell">
|
|
146
|
+
<div class="connected-toolbar">
|
|
147
|
+
<div class="connected-title-block">
|
|
148
|
+
<div class="connected-kicker" id="connected-kicker">Connected FRAIM</div>
|
|
149
|
+
<div class="connected-title" id="connected-title">Account</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="connected-origin" id="connected-origin"></div>
|
|
152
|
+
<button class="connected-close" id="connected-close" type="button">Back</button>
|
|
153
|
+
</div>
|
|
154
|
+
<iframe id="connected-frame" class="connected-frame" title="FRAIM connected surface" allow="clipboard-read; clipboard-write"></iframe>
|
|
155
|
+
</div>
|
|
156
|
+
</section>
|
|
157
|
+
|
|
158
|
+
<!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
|
|
145
159
|
<section class="hub-area on" id="area-projects">
|
|
146
160
|
<div class="proj-tabs" id="proj-tabs">
|
|
147
161
|
<button class="ptab on" id="ptab-overview" type="button" data-view="overview">Overview</button>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -47,6 +47,10 @@ const state = {
|
|
|
47
47
|
selectedPersonaKey: null, // R4: null = "All employees"
|
|
48
48
|
modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
|
|
49
49
|
storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
|
|
50
|
+
remoteBaseUrl: null,
|
|
51
|
+
connectedReturnArea: 'projects',
|
|
52
|
+
connectedSurface: null,
|
|
53
|
+
pendingConnectedSurfaceAfterAuth: null,
|
|
50
54
|
panelState: {}, // { [convId]: { coach?: boolean } }
|
|
51
55
|
wordContext: null, // WordContext pushed from taskpane.html via postMessage
|
|
52
56
|
// Pending coaching job selected via a quick-coach button or template picker.
|
|
@@ -144,6 +148,34 @@ async function requestJson(url, options) {
|
|
|
144
148
|
return payload;
|
|
145
149
|
}
|
|
146
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
|
+
|
|
147
179
|
function bootstrapCacheKey(projectPath, docUrl) {
|
|
148
180
|
return `${tfCanonicalProjectPath(projectPath || '')}::${docUrl || ''}`;
|
|
149
181
|
}
|
|
@@ -156,6 +188,10 @@ function cacheBootstrapPayload(bootstrap, docUrl) {
|
|
|
156
188
|
function applyBootstrap(bootstrap, docUrl) {
|
|
157
189
|
state.bootstrap = bootstrap;
|
|
158
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
|
+
}
|
|
159
195
|
state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
|
|
160
196
|
// Restore persona selection persisted on the server side.
|
|
161
197
|
if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
|
|
@@ -229,6 +265,20 @@ async function loadBootstrap(projectPath, docUrl, options) {
|
|
|
229
265
|
return bootstrap;
|
|
230
266
|
}
|
|
231
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
|
+
|
|
232
282
|
// ---------------------------------------------------------------------------
|
|
233
283
|
// Word context bridge (postMessage from taskpane.html parent frame)
|
|
234
284
|
// ---------------------------------------------------------------------------
|
|
@@ -5911,6 +5961,11 @@ function wireEvents() {
|
|
|
5911
5961
|
const isFirstRun = urlParams.get('firstRun') === 'true';
|
|
5912
5962
|
const surface = urlParams.get('surface') || 'hub';
|
|
5913
5963
|
const docUrl = urlParams.get('docUrl') || '';
|
|
5964
|
+
const requestedConnectedSurface = tfRequestedConnectedSurface();
|
|
5965
|
+
const returnedApiKey = tfConsumeReturnedApiKeyFromUrl();
|
|
5966
|
+
if (returnedApiKey && requestedConnectedSurface) {
|
|
5967
|
+
state.pendingConnectedSurfaceAfterAuth = requestedConnectedSurface;
|
|
5968
|
+
}
|
|
5914
5969
|
|
|
5915
5970
|
// Apply surface class before bootstrap so CSS takes effect immediately.
|
|
5916
5971
|
if (surface !== 'hub') document.body.dataset.surface = surface;
|
|
@@ -5920,9 +5975,17 @@ function wireEvents() {
|
|
|
5920
5975
|
|
|
5921
5976
|
try {
|
|
5922
5977
|
await loadBootstrap(null, docUrl);
|
|
5978
|
+
if (returnedApiKey) {
|
|
5979
|
+
await tfRememberConnectedApiKey(returnedApiKey);
|
|
5980
|
+
tfOpenRequestedConnectedSurface();
|
|
5981
|
+
}
|
|
5923
5982
|
await hydrateConversationsFromServer();
|
|
5924
5983
|
startBgConvPoll();
|
|
5925
5984
|
} catch (error) {
|
|
5985
|
+
if (hostedHubNeedsSignIn(error)) {
|
|
5986
|
+
redirectHostedHubToSignIn();
|
|
5987
|
+
return;
|
|
5988
|
+
}
|
|
5926
5989
|
showStatus(error.message, true);
|
|
5927
5990
|
return;
|
|
5928
5991
|
}
|
|
@@ -6205,7 +6268,7 @@ const STORAGE_KEY_ASSIGNMENTS_512 = 'fraim.aiHub.assignments.v1';
|
|
|
6205
6268
|
|
|
6206
6269
|
const tf = {
|
|
6207
6270
|
enabled: false,
|
|
6208
|
-
area: 'projects', // company | manager | projects | brain
|
|
6271
|
+
area: 'projects', // company | manager | projects | brain | connected
|
|
6209
6272
|
projectView: 'overview', // overview | workspace
|
|
6210
6273
|
activeProjectId: null,
|
|
6211
6274
|
projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
|
|
@@ -9241,11 +9304,143 @@ function tfCloseAccountMenu() {
|
|
|
9241
9304
|
if (menu) menu.classList.remove('open');
|
|
9242
9305
|
}
|
|
9243
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
|
+
|
|
9244
9439
|
// ---------------------------------------------------------------------------
|
|
9245
9440
|
// Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
|
|
9246
9441
|
// sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
|
|
9247
9442
|
// OS preference; here we reflect it in the toggle control and persist changes.
|
|
9248
|
-
//
|
|
9443
|
+
// Embedded connected surfaces receive live theme updates via postMessage.
|
|
9249
9444
|
// ---------------------------------------------------------------------------
|
|
9250
9445
|
function tfCurrentTheme() {
|
|
9251
9446
|
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
|
@@ -9265,10 +9460,15 @@ function tfApplyTheme(theme, persist) {
|
|
|
9265
9460
|
document.documentElement.setAttribute('data-theme', t);
|
|
9266
9461
|
if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
|
|
9267
9462
|
tfSyncThemeToggle(t);
|
|
9268
|
-
// Keep
|
|
9463
|
+
// Keep embedded surfaces in sync live, including the connected main-server frame.
|
|
9269
9464
|
try {
|
|
9270
9465
|
document.querySelectorAll('iframe').forEach((f) => {
|
|
9271
|
-
if (f.contentWindow)
|
|
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);
|
|
9272
9472
|
});
|
|
9273
9473
|
} catch (e) {}
|
|
9274
9474
|
}
|
|
@@ -10000,8 +10200,26 @@ function tfWireShell() {
|
|
|
10000
10200
|
const avatar = document.getElementById('avatar-btn');
|
|
10001
10201
|
if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
|
|
10002
10202
|
tfWireThemeToggle();
|
|
10203
|
+
const accountItem = document.getElementById('am-account');
|
|
10204
|
+
if (accountItem) accountItem.addEventListener('click', (e) => {
|
|
10205
|
+
e.preventDefault();
|
|
10206
|
+
tfOpenConnectedSurface('account');
|
|
10207
|
+
});
|
|
10003
10208
|
const brainItem = document.getElementById('am-brain');
|
|
10004
|
-
if (brainItem) brainItem.addEventListener('click', () => {
|
|
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
|
+
}
|
|
10005
10223
|
const signout = document.getElementById('am-signout');
|
|
10006
10224
|
if (signout) signout.addEventListener('click', () => { window.location.href = '/auth/sign-in.html'; });
|
|
10007
10225
|
document.addEventListener('click', (e) => {
|
|
@@ -10113,6 +10331,11 @@ function tfWireShell() {
|
|
|
10113
10331
|
];
|
|
10114
10332
|
document.addEventListener('keydown', (e) => {
|
|
10115
10333
|
if (e.key !== 'Escape') return;
|
|
10334
|
+
if (tf.area === 'connected') {
|
|
10335
|
+
e.stopPropagation();
|
|
10336
|
+
tfCloseConnectedSurface();
|
|
10337
|
+
return;
|
|
10338
|
+
}
|
|
10116
10339
|
for (const [id, fn] of escClosers) {
|
|
10117
10340
|
const el = document.getElementById(id);
|
|
10118
10341
|
if (el && !el.hidden) { e.stopPropagation(); fn(); return; }
|
|
@@ -10149,6 +10372,36 @@ function tfPopulateAccountMenu() {
|
|
|
10149
10372
|
if (avatarBtn) avatarBtn.textContent = id.initials;
|
|
10150
10373
|
}
|
|
10151
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
|
+
|
|
10152
10405
|
function tfInitShell() {
|
|
10153
10406
|
if (typeof surface !== 'undefined' && surface !== 'hub') return;
|
|
10154
10407
|
if (document.body.dataset.surface && document.body.dataset.surface !== 'hub') return;
|
|
@@ -10187,6 +10440,7 @@ function tfInitShell() {
|
|
|
10187
10440
|
// the project workspace. Re-render once the workspace is selected so manager
|
|
10188
10441
|
// delegation boards are visible on first load, not only after a refresh tick.
|
|
10189
10442
|
renderActive();
|
|
10443
|
+
tfOpenRequestedConnectedSurface();
|
|
10190
10444
|
}
|
|
10191
10445
|
|
|
10192
10446
|
// Refresh shell-derived UI after a bootstrap reload (e.g. project picked).
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -2752,7 +2752,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
2752
2752
|
.am-av { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
|
|
2753
2753
|
.am-name { font-size: 13px; font-weight: 700; }
|
|
2754
2754
|
.am-email { font-size: 11px; color: var(--muted); }
|
|
2755
|
-
.am-item { display: flex; align-items: flex-start; gap: 10px; padding: 9px 16px; cursor: pointer; font-size: 13px; }
|
|
2755
|
+
.am-item { display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 9px 16px; cursor: pointer; font-size: 13px; color: var(--text); text-align: left; text-decoration: none; border: 0; background: none; }
|
|
2756
2756
|
.am-item:hover { background: var(--bg); }
|
|
2757
2757
|
.am-ico { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; margin-top: 1px; }
|
|
2758
2758
|
.am-label { font-weight: 600; }
|
|
@@ -2768,12 +2768,75 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
2768
2768
|
.am-theme[aria-checked="true"] .am-switch-knob { transform: translateX(16px); }
|
|
2769
2769
|
|
|
2770
2770
|
/* Hub areas */
|
|
2771
|
-
.hub-area { display: none; flex: 1; overflow: hidden; }
|
|
2772
|
-
.hub-area.on { display: flex; flex-direction: column; }
|
|
2773
|
-
.hub-area-page { max-width: 860px; margin: 0 auto; padding: 28px 24px 48px; overflow-y: auto; flex: 1; /* authoritative rule — see also Round-2 override below which is now removed */ }
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2771
|
+
.hub-area { display: none; flex: 1; overflow: hidden; }
|
|
2772
|
+
.hub-area.on { display: flex; flex-direction: column; }
|
|
2773
|
+
.hub-area-page { max-width: 860px; margin: 0 auto; padding: 28px 24px 48px; overflow-y: auto; flex: 1; /* authoritative rule — see also Round-2 override below which is now removed */ }
|
|
2774
|
+
|
|
2775
|
+
.connected-shell {
|
|
2776
|
+
display: flex;
|
|
2777
|
+
flex: 1;
|
|
2778
|
+
min-height: 0;
|
|
2779
|
+
flex-direction: column;
|
|
2780
|
+
background: var(--bg);
|
|
2781
|
+
}
|
|
2782
|
+
.connected-toolbar {
|
|
2783
|
+
display: flex;
|
|
2784
|
+
align-items: center;
|
|
2785
|
+
gap: 14px;
|
|
2786
|
+
min-height: 52px;
|
|
2787
|
+
padding: 8px 16px;
|
|
2788
|
+
background: var(--surface);
|
|
2789
|
+
border-bottom: 1px solid var(--line);
|
|
2790
|
+
flex-shrink: 0;
|
|
2791
|
+
}
|
|
2792
|
+
.connected-title-block {
|
|
2793
|
+
min-width: 0;
|
|
2794
|
+
display: flex;
|
|
2795
|
+
flex-direction: column;
|
|
2796
|
+
gap: 1px;
|
|
2797
|
+
}
|
|
2798
|
+
.connected-kicker {
|
|
2799
|
+
font-size: 10px;
|
|
2800
|
+
font-weight: 700;
|
|
2801
|
+
letter-spacing: .04em;
|
|
2802
|
+
text-transform: uppercase;
|
|
2803
|
+
color: var(--muted);
|
|
2804
|
+
}
|
|
2805
|
+
.connected-title {
|
|
2806
|
+
font-size: 14px;
|
|
2807
|
+
font-weight: 700;
|
|
2808
|
+
color: var(--text);
|
|
2809
|
+
}
|
|
2810
|
+
.connected-origin {
|
|
2811
|
+
min-width: 0;
|
|
2812
|
+
flex: 1;
|
|
2813
|
+
color: var(--muted);
|
|
2814
|
+
font-size: 12px;
|
|
2815
|
+
overflow: hidden;
|
|
2816
|
+
text-overflow: ellipsis;
|
|
2817
|
+
white-space: nowrap;
|
|
2818
|
+
}
|
|
2819
|
+
.connected-close {
|
|
2820
|
+
border: 1px solid var(--line);
|
|
2821
|
+
background: var(--surface);
|
|
2822
|
+
color: var(--text);
|
|
2823
|
+
border-radius: 8px;
|
|
2824
|
+
padding: 6px 12px;
|
|
2825
|
+
font-size: 13px;
|
|
2826
|
+
font-weight: 600;
|
|
2827
|
+
flex-shrink: 0;
|
|
2828
|
+
}
|
|
2829
|
+
.connected-close:hover { background: var(--bg); }
|
|
2830
|
+
.connected-frame {
|
|
2831
|
+
flex: 1;
|
|
2832
|
+
min-height: 0;
|
|
2833
|
+
width: 100%;
|
|
2834
|
+
border: 0;
|
|
2835
|
+
background: var(--surface);
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/* #521 R6: Company/Manager use the same rail + main shell as the project workspace. */
|
|
2839
|
+
.area-shell { display: flex; flex: 1; min-height: 0; }
|
|
2777
2840
|
.area-rail { width: 244px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--line); overflow-y: auto; padding: 6px 0; }
|
|
2778
2841
|
.area-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
2779
2842
|
.area-rail-head { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 14px 16px 6px; }
|