@robosystems/core 0.8.2 → 0.8.4

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.
@@ -24,4 +24,6 @@ export declare const getAppConfig: (appName: string) => AppConfig;
24
24
  */
25
25
  export declare const LOGIN_HOME_APP: AppName;
26
26
  export declare const getLoginHomeUrl: () => string;
27
+ /** Product-facing name of the login home, for "managed on X" copy. */
28
+ export declare const getLoginHomeName: () => string;
27
29
  export declare const isLoginHome: () => boolean;
@@ -61,4 +61,6 @@ export const getAppConfig = (appName) => {
61
61
  */
62
62
  export const LOGIN_HOME_APP = process.env.NEXT_PUBLIC_LOGIN_HOME_APP || 'robosystems';
63
63
  export const getLoginHomeUrl = () => getAppConfig(LOGIN_HOME_APP).url;
64
+ /** Product-facing name of the login home, for "managed on X" copy. */
65
+ export const getLoginHomeName = () => getAppConfig(LOGIN_HOME_APP).displayName;
64
66
  export const isLoginHome = () => CURRENT_APP === LOGIN_HOME_APP;
@@ -1,4 +1,17 @@
1
1
  import type { AppConfig, AuthUser } from './types';
2
+ export interface SSORedirectUrlOptions {
3
+ /**
4
+ * Mirror the target app / return URL into this tab's sessionStorage as a
5
+ * same-domain fallback for `handleSSOLogin`. Default true.
6
+ *
7
+ * Pass false when the URL is being handed to a *different* tab: the hints
8
+ * are unreadable there (separate tab, separate origin) and would linger in
9
+ * this tab, where a later same-tab SSO completion with no `returnUrl`
10
+ * param would pick up the stale path and redirect somewhere the user
11
+ * never asked to go.
12
+ */
13
+ persistSessionHints?: boolean;
14
+ }
2
15
  export declare class SSOManager {
3
16
  private authClient;
4
17
  constructor(apiUrl: string);
@@ -13,7 +26,7 @@ export declare class SSOManager {
13
26
  /**
14
27
  * Get SSO redirect URL using secure POST-based flow
15
28
  */
16
- getSSORedirectUrl(targetApp: string, returnUrl?: string): Promise<string>;
29
+ getSSORedirectUrl(targetApp: string, returnUrl?: string, options?: SSORedirectUrlOptions): Promise<string>;
17
30
  /**
18
31
  * Handle SSO login using secure POST-based flow
19
32
  */
@@ -37,6 +50,7 @@ export declare class SSOManager {
37
50
  export declare function useSSO(apiUrl: string): {
38
51
  checkSSOAuthentication: () => Promise<AuthUser>;
39
52
  handleSSOLogin: () => Promise<AuthUser>;
53
+ getSSORedirectUrl: (targetApp: string, returnUrl?: string, options?: SSORedirectUrlOptions) => Promise<string>;
40
54
  navigateToApp: (targetApp: string, returnUrl?: string) => Promise<void>;
41
55
  getAvailableApps: () => AppConfig[];
42
56
  };
package/auth-core/sso.js CHANGED
@@ -33,7 +33,8 @@ export class SSOManager {
33
33
  /**
34
34
  * Get SSO redirect URL using secure POST-based flow
35
35
  */
36
- async getSSORedirectUrl(targetApp, returnUrl) {
36
+ async getSSORedirectUrl(targetApp, returnUrl, options = {}) {
37
+ const { persistSessionHints = true } = options;
37
38
  const ssoData = await this.generateSSOToken();
38
39
  const appConfig = APP_CONFIGS[targetApp];
39
40
  if (!appConfig) {
@@ -50,15 +51,17 @@ export class SSOManager {
50
51
  }
51
52
  // Store only non-sensitive metadata in sessionStorage as backup for same-domain cases
52
53
  // Note: We avoid storing the session_id in sessionStorage for security
53
- try {
54
- sessionStorage.setItem('sso_target_app', targetApp);
55
- if (returnUrl) {
56
- sessionStorage.setItem('sso_return_url', returnUrl);
54
+ if (persistSessionHints) {
55
+ try {
56
+ sessionStorage.setItem('sso_target_app', targetApp);
57
+ if (returnUrl) {
58
+ sessionStorage.setItem('sso_return_url', returnUrl);
59
+ }
60
+ }
61
+ catch (error) {
62
+ // Storage error - continue without backup storage
63
+ debugLog('sessionStorage write failed during SSO URL generation', error);
57
64
  }
58
- }
59
- catch (error) {
60
- // Storage error - continue without backup storage
61
- debugLog('sessionStorage write failed during SSO URL generation', error);
62
65
  }
63
66
  return url.toString();
64
67
  }
@@ -273,6 +276,7 @@ export function useSSO(apiUrl) {
273
276
  return useMemo(() => ({
274
277
  checkSSOAuthentication: () => ssoManager.checkSSOAuthentication(),
275
278
  handleSSOLogin: () => ssoManager.handleSSOLogin(),
279
+ getSSORedirectUrl: (targetApp, returnUrl, options) => ssoManager.getSSORedirectUrl(targetApp, returnUrl, options),
276
280
  navigateToApp: (targetApp, returnUrl) => ssoManager.navigateToApp(targetApp, returnUrl),
277
281
  getAvailableApps: () => ssoManager.getAvailableApps(),
278
282
  }), [ssoManager]);
@@ -90,7 +90,8 @@ export function ConsoleContent({ config }) {
90
90
  (config.enableRecall
91
91
  ? ` /recall - Recall semantic memories\n`
92
92
  : '') +
93
- ` /mcp - Show MCP connection setup\n` +
93
+ ` /mcp - Connect an MCP client (sign in — no key)\n` +
94
+ ` /mcp key - Mint a graph-scoped API key for header-only clients\n` +
94
95
  ` /help - Show this help message\n` +
95
96
  ` /clear - Clear console history\n` +
96
97
  ` /examples - Show example queries`;
@@ -405,32 +406,54 @@ export function ConsoleContent({ config }) {
405
406
  }
406
407
  }
407
408
  };
408
- const showMcpSetup = async () => {
409
- addSystemMessage('Creating graph-scoped connector key...', true);
409
+ const showMcpSetup = async (mode) => {
410
410
  const apiUrl = process.env.NEXT_PUBLIC_ROBOSYSTEMS_API_URL ||
411
411
  'https://api.robosystems.ai';
412
412
  const { mcp } = config;
413
413
  const contextId = graphId || mcp.contextIdFallback;
414
+ const endpoint = `${apiUrl}/v1/graphs/${contextId}/mcp`;
415
+ const connectorName = `${mcp.serverName}-${contextId}`;
416
+ const exampleLines = mcp.exampleQuestions
417
+ .map((q) => ` • "${q}"`)
418
+ .join('\n');
419
+ // The graph id lives in the URL path and never becomes a tool argument,
420
+ // so one connection is anchored to exactly one graph. OAuth on the
421
+ // per-graph URL shows this graph preselected on the consent screen; no
422
+ // key is minted unless asked for, so a sign-in user never leaves an
423
+ // unused credential behind in Settings → API Keys.
424
+ if (mode !== 'key') {
425
+ addSystemMessage(`MCP — connect ${contextId}\n` +
426
+ `═══════════════════════════════════════════════════════════════\n\n` +
427
+ `Sign in to connect (recommended). Paste the URL; the client sends\n` +
428
+ `you to sign in, and this graph is already selected on the consent\n` +
429
+ `screen. Nothing to copy, store, or rotate.\n\n` +
430
+ `Claude (claude.ai / Desktop)\n` +
431
+ ` Settings → Connectors → Add custom connector\n` +
432
+ ` ${endpoint}\n\n` +
433
+ `Claude Code (then /mcp to sign in)\n` +
434
+ ` claude mcp add --transport http ${connectorName} ${endpoint}\n\n` +
435
+ `Cursor / VS Code (mcp.json)\n` +
436
+ ` "${connectorName}": { "url": "${endpoint}" }\n\n` +
437
+ `Any graph from one address: ${apiUrl}/v1/mcp — pick the graph at\n` +
438
+ `sign-in.\n\n` +
439
+ `Scripts, CI, or a client that can't sign in? Type /mcp key to mint\n` +
440
+ `an API key scoped to this graph, with the header snippets filled in.\n\n` +
441
+ `Once connected, ask questions like:\n` +
442
+ `${exampleLines}\n\n` +
443
+ `Clients without HTTP transport support can use the stdio bridge\n` +
444
+ `in proxy mode: https://github.com/RoboFinSystems/robosystems-mcp-client`, true);
445
+ return;
446
+ }
447
+ addSystemMessage('Creating graph-scoped API key...', true);
414
448
  try {
415
449
  const { createMcpConnectorUrl } = await import('../../lib/mcp-connector');
416
- // The graph id lives in the URL path and never becomes a tool argument,
417
- // so one connector is anchored to exactly one graph. Multi-graph users
418
- // add one connector per graph, which is why the name carries the id.
419
- // The key is graph-scoped: valid only for this graph, revocable in
420
- // Settings → API Keys, and rejected on every account-level surface.
450
+ // Graph-scoped: valid only for this graph and its subgraphs, rejected on
451
+ // every account-level surface, revocable in Settings → API Keys.
421
452
  const connector = await createMcpConnectorUrl(contextId, { apiUrl });
422
- const connectorName = `${mcp.serverName}-${contextId}`;
423
- const exampleLines = mcp.exampleQuestions
424
- .map((q) => ` • "${q}"`)
425
- .join('\n');
426
- addSystemMessage(`MCP Setup Instructions:\n` +
453
+ addSystemMessage(`API key for ${contextId}\n` +
427
454
  `═══════════════════════════════════════════════════════════════\n\n` +
428
- `Graph-scoped API key created — it works only for ${contextId}\n` +
429
- `and can be revoked anytime in Settings → API Keys.\n\n` +
430
- `Claude (claude.ai / Desktop)\n` +
431
- ` Settings → Connectors → Add custom connector, then paste this\n` +
432
- ` URL (the key rides inside it — no header field needed):\n` +
433
- ` ${connector.url}\n\n` +
455
+ `Scoped to this graph only; revoke it anytime in Settings → API Keys.\n` +
456
+ `It goes in the X-API-Key header — never in the URL.\n\n` +
434
457
  `Claude Code\n` +
435
458
  ` claude mcp add --transport http ${connectorName} \\\n` +
436
459
  ` ${connector.endpoint} \\\n` +
@@ -440,16 +463,12 @@ export function ConsoleContent({ config }) {
440
463
  ` "url": "${connector.endpoint}",\n` +
441
464
  ` "headers": { "X-API-Key": "${connector.apiKey}" }\n` +
442
465
  ` }\n\n` +
443
- `Once connected, ask Claude questions like:\n` +
444
- `${exampleLines}\n\n` +
445
- `Clients without HTTP transport support can use the stdio bridge\n` +
446
- `in proxy mode: https://github.com/RoboFinSystems/robosystems-mcp-client\n\n` +
447
- `The connector URL contains the key — treat it like a password.`, true);
466
+ `Treat the key like a password. Shown once.`, true);
448
467
  }
449
468
  catch (error) {
450
- addErrorMessage(`Failed to create connector key: ${error.message || 'Unknown error'}\n\n` +
451
- `You can create an API key in Settings and connect with:\n` +
452
- ` URL: ${apiUrl}/v1/graphs/${contextId}/mcp\n` +
469
+ addErrorMessage(`Failed to create API key: ${error.message || 'Unknown error'}\n\n` +
470
+ `You can create a key in Settings and connect with:\n` +
471
+ ` URL: ${endpoint}\n` +
453
472
  ` Header: X-API-Key: <your key>`);
454
473
  }
455
474
  };
@@ -602,7 +621,7 @@ export function ConsoleContent({ config }) {
602
621
  return;
603
622
  }
604
623
  case '/mcp':
605
- await showMcpSetup();
624
+ await showMcpSetup(command.slice(cmd.length).trim().toLowerCase());
606
625
  return;
607
626
  default:
608
627
  addErrorMessage(`Unknown command: ${cmd}\n\nType /help for available commands.`);
package/hooks/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { useApiError } from './use-api-error';
2
+ export { useCrossAppLink, type CrossAppTarget, type OpenCrossAppOptions, type UseCrossAppLinkResult, } from './use-cross-app-link';
2
3
  export { useMediaQuery } from './use-media-query';
3
4
  export { useStreamingQuery } from './use-streaming-query';
4
5
  export { useToast, type ToastMessage } from './use-toast';
package/hooks/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { useApiError } from './use-api-error';
2
+ export { useCrossAppLink, } from './use-cross-app-link';
2
3
  export { useMediaQuery } from './use-media-query';
3
4
  export { useStreamingQuery } from './use-streaming-query';
4
5
  export { useToast } from './use-toast';
@@ -0,0 +1,38 @@
1
+ export type CrossAppTarget = '_blank' | '_self';
2
+ export interface OpenCrossAppOptions {
3
+ /** Relative path on the target app, e.g. `/settings`. */
4
+ path?: string;
5
+ /**
6
+ * `_blank` (default) hands the destination to a new tab, leaving the
7
+ * current app exactly where it was — right for a *detour* (account
8
+ * settings, graph creation), where the user is coming back.
9
+ *
10
+ * `_self` navigates in place — right when leaving *is* the intent, as in
11
+ * the app switcher.
12
+ */
13
+ target?: CrossAppTarget;
14
+ }
15
+ export interface UseCrossAppLinkResult {
16
+ /** Resolves true once the destination has been handed off. */
17
+ openApp: (targetApp: string, options?: OpenCrossAppOptions) => Promise<boolean>;
18
+ /** Convenience wrapper targeting the configured login home. */
19
+ openLoginHome: (options?: OpenCrossAppOptions) => Promise<boolean>;
20
+ /** True while the SSO handoff is in flight. */
21
+ isOpening: boolean;
22
+ }
23
+ /**
24
+ * Open another RoboSystems app at a specific path, in one click.
25
+ *
26
+ * Cross-app navigation can't be a plain `<a href>`: the destination doesn't
27
+ * exist until an SSO token is minted and exchanged for a session ID, two
28
+ * round-trips deep. The apps sit on separate registrable domains, so a naked
29
+ * link to the target would drop the user on a login screen instead.
30
+ *
31
+ * That async gap is also why the new tab is claimed *synchronously*, before
32
+ * the first await. A `window.open()` issued after an await is outside the
33
+ * user-gesture stack and popup blockers swallow it; opening `about:blank`
34
+ * inside the gesture and pointing it at the URL afterwards is the standard
35
+ * way around that. If the open is refused anyway (popups hard-blocked), we
36
+ * degrade to a same-tab navigation rather than dead-ending the click.
37
+ */
38
+ export declare function useCrossAppLink(apiUrl?: string): UseCrossAppLinkResult;
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+ import { useCallback, useMemo, useState } from 'react';
3
+ import { LOGIN_HOME_APP } from '../auth-core/config';
4
+ import { SSOManager } from '../auth-core/sso';
5
+ const DEFAULT_API_URL = process.env.NEXT_PUBLIC_ROBOSYSTEMS_API_URL || 'http://localhost:8000';
6
+ /**
7
+ * Open another RoboSystems app at a specific path, in one click.
8
+ *
9
+ * Cross-app navigation can't be a plain `<a href>`: the destination doesn't
10
+ * exist until an SSO token is minted and exchanged for a session ID, two
11
+ * round-trips deep. The apps sit on separate registrable domains, so a naked
12
+ * link to the target would drop the user on a login screen instead.
13
+ *
14
+ * That async gap is also why the new tab is claimed *synchronously*, before
15
+ * the first await. A `window.open()` issued after an await is outside the
16
+ * user-gesture stack and popup blockers swallow it; opening `about:blank`
17
+ * inside the gesture and pointing it at the URL afterwards is the standard
18
+ * way around that. If the open is refused anyway (popups hard-blocked), we
19
+ * degrade to a same-tab navigation rather than dead-ending the click.
20
+ */
21
+ export function useCrossAppLink(apiUrl = DEFAULT_API_URL) {
22
+ const ssoManager = useMemo(() => new SSOManager(apiUrl), [apiUrl]);
23
+ const [isOpening, setIsOpening] = useState(false);
24
+ const openApp = useCallback(async (targetApp, options = {}) => {
25
+ const { path, target = '_blank' } = options;
26
+ // Claim the tab inside the gesture — see the note above.
27
+ const childWindow = target === '_blank' ? window.open('', '_blank') : null;
28
+ const openedNewTab = childWindow !== null;
29
+ setIsOpening(true);
30
+ try {
31
+ const url = await ssoManager.getSSORedirectUrl(targetApp, path, {
32
+ persistSessionHints: !openedNewTab,
33
+ });
34
+ if (childWindow) {
35
+ childWindow.location.href = url;
36
+ }
37
+ else {
38
+ window.location.href = url;
39
+ }
40
+ return true;
41
+ }
42
+ catch (error) {
43
+ // Leaving a blank tab behind is worse than no tab at all.
44
+ childWindow === null || childWindow === void 0 ? void 0 : childWindow.close();
45
+ if (process.env.NODE_ENV === 'development') {
46
+ console.error(`[SSO] Failed to open ${targetApp}${path ? path : ''}:`, error);
47
+ }
48
+ return false;
49
+ }
50
+ finally {
51
+ setIsOpening(false);
52
+ }
53
+ }, [ssoManager]);
54
+ const openLoginHome = useCallback((options) => openApp(LOGIN_HOME_APP, options), [openApp]);
55
+ return { openApp, openLoginHome, isOpening };
56
+ }
@@ -1,7 +1,5 @@
1
1
  export interface McpConnectorUrl {
2
- /** Pasteable connector URL with the graph-scoped key embedded as `?token=` */
3
- url: string;
4
- /** Bare MCP endpoint URL (no credential) — for header-capable clients */
2
+ /** Bare per-graph MCP endpoint URL (no credential) */
5
3
  endpoint: string;
6
4
  /** The plaintext graph-scoped API key (returned once at mint) */
7
5
  apiKey: string;
@@ -10,18 +8,16 @@ export interface McpConnectorUrl {
10
8
  graphId: string;
11
9
  }
12
10
  /**
13
- * Mint a graph-scoped API key and assemble the MCP connector URL for it.
11
+ * Mint a graph-scoped API key for the per-graph MCP endpoint.
14
12
  *
15
- * The URL is for clients that cannot send custom headers (claude.ai /
16
- * Claude Desktop custom connectors): the scoped key travels as a `token`
17
- * query parameter, which the server honors only on the MCP endpoint and
18
- * only for keys scoped to that URL's graph — account-wide keys are
19
- * rejected there. The key works for exactly this graph (and its
20
- * subgraphs), is rejected on every account-level surface, and can be
21
- * revoked from Settings → API Keys like any other key.
22
- *
23
- * Header-capable clients (Claude Code, Cursor, VS Code) should use the
24
- * bare `endpoint` with the key in an `X-API-Key` header instead.
13
+ * For clients that cannot sign in (scripts, CI, editors without OAuth):
14
+ * the key goes in an `X-API-Key` header on `endpoint`. It works for
15
+ * exactly this graph (and its subgraphs), is rejected on every
16
+ * account-level surface, and can be revoked from Settings → API Keys like
17
+ * any other key. Credentials never travel in the URL — the `?token=`
18
+ * connector URL was the bridge to OAuth and the server no longer honors
19
+ * it. OAuth-capable clients need no key at all: they add `endpoint` (or
20
+ * the graph-agnostic `/v1/mcp`) and sign in.
25
21
  */
26
22
  export declare function createMcpConnectorUrl(graphId: string, options?: {
27
23
  apiUrl?: string;
@@ -1,17 +1,15 @@
1
1
  const DEFAULT_API_URL = 'https://api.robosystems.ai';
2
2
  /**
3
- * Mint a graph-scoped API key and assemble the MCP connector URL for it.
3
+ * Mint a graph-scoped API key for the per-graph MCP endpoint.
4
4
  *
5
- * The URL is for clients that cannot send custom headers (claude.ai /
6
- * Claude Desktop custom connectors): the scoped key travels as a `token`
7
- * query parameter, which the server honors only on the MCP endpoint and
8
- * only for keys scoped to that URL's graph — account-wide keys are
9
- * rejected there. The key works for exactly this graph (and its
10
- * subgraphs), is rejected on every account-level surface, and can be
11
- * revoked from Settings → API Keys like any other key.
12
- *
13
- * Header-capable clients (Claude Code, Cursor, VS Code) should use the
14
- * bare `endpoint` with the key in an `X-API-Key` header instead.
5
+ * For clients that cannot sign in (scripts, CI, editors without OAuth):
6
+ * the key goes in an `X-API-Key` header on `endpoint`. It works for
7
+ * exactly this graph (and its subgraphs), is rejected on every
8
+ * account-level surface, and can be revoked from Settings → API Keys like
9
+ * any other key. Credentials never travel in the URL — the `?token=`
10
+ * connector URL was the bridge to OAuth and the server no longer honors
11
+ * it. OAuth-capable clients need no key at all: they add `endpoint` (or
12
+ * the graph-agnostic `/v1/mcp`) and sign in.
15
13
  */
16
14
  export async function createMcpConnectorUrl(graphId, options = {}) {
17
15
  var _a, _b;
@@ -26,7 +24,6 @@ export async function createMcpConnectorUrl(graphId, options = {}) {
26
24
  }
27
25
  const endpoint = `${apiUrl}/v1/graphs/${graphId}/mcp`;
28
26
  return {
29
- url: `${endpoint}?token=${response.data.key}`,
30
27
  endpoint,
31
28
  apiKey: response.data.key,
32
29
  keyName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robosystems/core",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Shared RoboSystems frontend core library (auth, contexts, task monitoring, UI components) for the RoboSystems Next.js apps",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -2,13 +2,14 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { DarkThemeToggle, Dropdown, DropdownDivider, DropdownHeader, DropdownItem, Navbar as FlowbiteNavbar, NavbarBrand, Tooltip, } from 'flowbite-react';
4
4
  import Link from 'next/link';
5
- import { HiMenuAlt1, HiUserCircle, HiX } from 'react-icons/hi';
5
+ import { HiExternalLink, HiMenuAlt1, HiUserCircle, HiX } from 'react-icons/hi';
6
6
  import { AppSwitcher } from '../../auth-components';
7
7
  import { useAuth } from '../../auth-components/AuthProvider';
8
8
  import { useSidebarContext } from '../../contexts';
9
9
  import { useMediaQuery, useUser } from '../../hooks';
10
10
  import { customTheme } from '../../theme';
11
11
  import { LogoBadge } from '../Logo';
12
+ import { useAccountSettingsLink } from './account-settings';
12
13
  import { ThemeToggle } from './ThemeToggle';
13
14
  export function CoreNavbar({ appName, currentApp, apiUrl = process.env.NEXT_PUBLIC_ROBOSYSTEMS_API_URL ||
14
15
  'http://localhost:8000', homeHref = '/home', logoAltText, additionalComponents, className = '', borderColorClass = 'dark:border-gray-700', useCustomThemeToggle = true, showAppSwitcherFirst = false, }) {
@@ -43,10 +44,13 @@ export function CoreNavbar({ appName, currentApp, apiUrl = process.env.NEXT_PUBL
43
44
  return (_jsxs(_Fragment, { children: [_jsx("div", { className: "hidden dark:block", children: _jsx(Tooltip, { content: "Toggle light mode", theme: customTheme.tooltip, children: _jsx(DarkThemeToggle, {}) }) }), _jsx("div", { className: "dark:hidden", children: _jsx(Tooltip, { content: "Toggle dark mode", theme: customTheme.tooltip, children: _jsx(DarkThemeToggle, {}) }) })] }));
44
45
  };
45
46
  const renderAppSwitcher = () => (_jsx(Tooltip, { content: "Switch apps", theme: customTheme.tooltip, children: _jsx(AppSwitcher, { apiUrl: apiUrl, currentApp: currentApp }) }));
46
- return (_jsx(FlowbiteNavbar, { fluid: true, theme: customTheme.navbar, className: `fixed top-0 z-30 w-full border-b border-gray-200 p-0 ${borderColorClass} bg-white sm:p-0 dark:bg-black ${className}`, children: _jsx("div", { className: "w-full p-3 pr-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center", children: [_jsxs("button", { onClick: handleToggleSidebar, className: "mr-3 cursor-pointer rounded-sm p-2 text-gray-600 hover:bg-zinc-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-zinc-700 dark:hover:text-white", children: [_jsx("span", { className: "sr-only", children: "Toggle sidebar" }), _jsx("div", { className: "lg:hidden", children: sidebar.mobile.isOpen ? (_jsx(HiX, { className: "h-6 w-6" })) : (_jsx(HiMenuAlt1, { className: "h-6 w-6" })) }), _jsx("div", { className: "hidden lg:block", children: _jsx(HiMenuAlt1, { className: "h-6 w-6" }) })] }), _jsxs(NavbarBrand, { as: Link, href: homeHref, className: "mr-14", children: [_jsx(LogoBadge, { app: currentApp, animate: "once", className: "mr-2 h-10 w-10" }), _jsx("span", { className: "font-heading mt-2 ml-1 self-center text-2xl font-semibold whitespace-nowrap text-gray-900 dark:text-white", children: appName })] })] }), _jsx("div", { className: "flex items-center lg:gap-3", children: _jsxs("div", { className: "flex items-center", children: [additionalComponents && (_jsx("div", { className: "mr-2 hidden md:block", children: additionalComponents })), _jsxs("div", { className: "flex items-center space-x-2", children: [showAppSwitcherFirst && renderAppSwitcher(), renderThemeToggle(), !showAppSwitcherFirst && renderAppSwitcher(), _jsx(UserDropdown, { user: user, onLogout: handleLogout })] })] }) })] }) }) }));
47
+ return (_jsx(FlowbiteNavbar, { fluid: true, theme: customTheme.navbar, className: `fixed top-0 z-30 w-full border-b border-gray-200 p-0 ${borderColorClass} bg-white sm:p-0 dark:bg-black ${className}`, children: _jsx("div", { className: "w-full p-3 pr-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center", children: [_jsxs("button", { onClick: handleToggleSidebar, className: "mr-3 cursor-pointer rounded-sm p-2 text-gray-600 hover:bg-zinc-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-zinc-700 dark:hover:text-white", children: [_jsx("span", { className: "sr-only", children: "Toggle sidebar" }), _jsx("div", { className: "lg:hidden", children: sidebar.mobile.isOpen ? (_jsx(HiX, { className: "h-6 w-6" })) : (_jsx(HiMenuAlt1, { className: "h-6 w-6" })) }), _jsx("div", { className: "hidden lg:block", children: _jsx(HiMenuAlt1, { className: "h-6 w-6" }) })] }), _jsxs(NavbarBrand, { as: Link, href: homeHref, className: "mr-14", children: [_jsx(LogoBadge, { app: currentApp, animate: "once", className: "mr-2 h-10 w-10" }), _jsx("span", { className: "font-heading mt-2 ml-1 self-center text-2xl font-semibold whitespace-nowrap text-gray-900 dark:text-white", children: appName })] })] }), _jsx("div", { className: "flex items-center lg:gap-3", children: _jsxs("div", { className: "flex items-center", children: [additionalComponents && (_jsx("div", { className: "mr-2 hidden md:block", children: additionalComponents })), _jsxs("div", { className: "flex items-center space-x-2", children: [showAppSwitcherFirst && renderAppSwitcher(), renderThemeToggle(), !showAppSwitcherFirst && renderAppSwitcher(), _jsx(UserDropdown, { user: user, apiUrl: apiUrl, onLogout: handleLogout })] })] }) })] }) }) }));
47
48
  }
48
- function UserDropdown({ user, onLogout }) {
49
- return (_jsx("div", { className: "relative", children: _jsxs(Dropdown, { theme: Object.assign(Object.assign({}, customTheme.dropdown), { floating: Object.assign(Object.assign({}, customTheme.dropdown.floating), { base: `${customTheme.dropdown.floating.base} z-50! w-64` }) }), arrowIcon: false, inline: true, label: _jsxs("span", { className: "inline-flex h-10 w-10 items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-zinc-100 focus:ring-2 focus:ring-gray-200 focus:outline-hidden dark:text-gray-400 dark:hover:bg-zinc-700 dark:focus:ring-gray-600", children: [_jsx("span", { className: "sr-only", children: "User menu" }), _jsx(HiUserCircle, { className: "h-6 w-6" })] }), children: [_jsxs(DropdownHeader, { theme: { header: customTheme.dropdown.floating.header }, className: "px-4 py-3", children: [_jsx("div", { className: "block text-sm font-medium text-gray-900 dark:text-white", children: (user === null || user === void 0 ? void 0 : user.name) || 'User' }), (user === null || user === void 0 ? void 0 : user.email) && (_jsx("div", { className: "block truncate text-sm text-gray-500 dark:text-gray-400", children: user.email }))] }), _jsx(DropdownItem, { theme: customTheme.dropdown.floating.item, as: Link, href: "/settings", className: "flex w-full items-center space-x-3 p-3", children: "User Settings" }), _jsx(DropdownDivider, { theme: { divider: customTheme.dropdown.floating.divider } }), _jsx(DropdownItem, { theme: customTheme.dropdown.floating.item, onClick: () => {
49
+ function UserDropdown({ user, apiUrl, onLogout }) {
50
+ const settings = useAccountSettingsLink(apiUrl);
51
+ return (_jsx("div", { className: "relative", children: _jsxs(Dropdown, { theme: Object.assign(Object.assign({}, customTheme.dropdown), { floating: Object.assign(Object.assign({}, customTheme.dropdown.floating), { base: `${customTheme.dropdown.floating.base} z-50! w-64` }) }), arrowIcon: false, inline: true, label: _jsxs("span", { className: "inline-flex h-10 w-10 items-center justify-center rounded-lg text-sm text-gray-500 hover:bg-zinc-100 focus:ring-2 focus:ring-gray-200 focus:outline-hidden dark:text-gray-400 dark:hover:bg-zinc-700 dark:focus:ring-gray-600", children: [_jsx("span", { className: "sr-only", children: "User menu" }), _jsx(HiUserCircle, { className: "h-6 w-6" })] }), children: [_jsxs(DropdownHeader, { theme: { header: customTheme.dropdown.floating.header }, className: "px-4 py-3", children: [_jsx("div", { className: "block text-sm font-medium text-gray-900 dark:text-white", children: (user === null || user === void 0 ? void 0 : user.name) || 'User' }), (user === null || user === void 0 ? void 0 : user.email) && (_jsx("div", { className: "block truncate text-sm text-gray-500 dark:text-gray-400", children: user.email }))] }), settings.isCrossApp ? (_jsxs(DropdownItem, { theme: customTheme.dropdown.floating.item, onClick: () => {
52
+ void settings.open();
53
+ }, title: settings.description, className: "flex w-full items-center justify-between p-3", children: [_jsx("span", { children: "User Settings" }), _jsx(HiExternalLink, { "aria-hidden": "true", className: "ml-3 h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" }), _jsx("span", { className: "sr-only", children: ` (opens ${settings.hostAppName} in a new tab)` })] })) : (_jsx(DropdownItem, { theme: customTheme.dropdown.floating.item, as: Link, href: settings.href, className: "flex w-full items-center space-x-3 p-3", children: "User Settings" })), _jsx(DropdownDivider, { theme: { divider: customTheme.dropdown.floating.divider } }), _jsx(DropdownItem, { theme: customTheme.dropdown.floating.item, onClick: () => {
50
54
  onLogout();
51
55
  }, className: "flex w-full items-center space-x-3 p-3", children: "Sign out" })] }) }));
52
56
  }
@@ -7,6 +7,7 @@ import { HiCog, HiOfficeBuilding } from 'react-icons/hi';
7
7
  import { twMerge } from 'tailwind-merge';
8
8
  import { useOrg, useSidebarContext } from '../../contexts';
9
9
  import { customTheme } from '../../theme';
10
+ import { useAccountSettingsLink } from './account-settings';
10
11
  export function CoreSidebar({ navigationItems, features = {}, bottomMenuActions = [], additionalMobileComponents, className = '', borderColorClass = 'dark:border-gray-700', }) {
11
12
  return (_jsxs(_Fragment, { children: [_jsx("div", { className: "lg:hidden", children: _jsx(MobileSidebar, { navigationItems: navigationItems, features: features, bottomMenuActions: bottomMenuActions, additionalMobileComponents: additionalMobileComponents, borderColorClass: borderColorClass }) }), _jsx("div", { className: `hidden lg:block ${className}`, children: _jsx(DesktopSidebar, { navigationItems: navigationItems, features: features, bottomMenuActions: bottomMenuActions, borderColorClass: borderColorClass }) })] }));
12
13
  }
@@ -57,6 +58,19 @@ function OrgSection({ isCollapsed, showOrgSection = true, }) {
57
58
  ? 'bg-zinc-100 text-gray-900 dark:bg-zinc-800 dark:text-gray-200'
58
59
  : '', children: _jsx("span", { className: "truncate text-sm", children: currentOrg.name }) }) }));
59
60
  }
61
+ /**
62
+ * The account-settings cog. A plain link on the login home; on the product
63
+ * apps an SSO handoff into a new tab, so this app keeps its place.
64
+ */
65
+ function SettingsButton() {
66
+ const settings = useAccountSettingsLink();
67
+ if (!settings.isCrossApp) {
68
+ return (_jsx(Tooltip, { content: settings.description, theme: customTheme.tooltip, children: _jsx(Button, { as: Link, href: settings.href, color: "ghost", className: "rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700", children: _jsx(HiCog, { className: "text-lg text-gray-500 dark:text-gray-400" }) }) }));
69
+ }
70
+ return (_jsx(Tooltip, { content: settings.description, theme: customTheme.tooltip, children: _jsx(Button, { onClick: () => {
71
+ void settings.open();
72
+ }, disabled: settings.isOpening, color: "ghost", "aria-label": settings.description, className: "rounded-lg p-2 hover:bg-gray-100 disabled:opacity-50 dark:hover:bg-gray-700", children: _jsx(HiCog, { className: "text-lg text-gray-500 dark:text-gray-400" }) }) }));
73
+ }
60
74
  function BottomMenu({ isCollapsed, features, bottomMenuActions, }) {
61
- return (_jsxs("div", { className: `flex items-center justify-center border-t border-gray-200 px-4 py-4 dark:border-gray-700 ${isCollapsed ? 'flex-col gap-y-2' : 'gap-x-5'}`, children: [_jsx(Tooltip, { content: "Settings", theme: customTheme.tooltip, children: _jsx(Button, { as: Link, href: "/settings", color: "ghost", className: "rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700", children: _jsx(HiCog, { className: "text-lg text-gray-500 dark:text-gray-400" }) }) }), bottomMenuActions.map((action, index) => (_jsx(Tooltip, { content: action.tooltip, theme: customTheme.tooltip, children: _jsx(Button, { onClick: action.onClick, color: "ghost", className: "rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700", children: _jsx(action.icon, { className: "text-lg text-gray-500 dark:text-gray-400" }) }) }, index)))] }));
75
+ return (_jsxs("div", { className: `flex items-center justify-center border-t border-gray-200 px-4 py-4 dark:border-gray-700 ${isCollapsed ? 'flex-col gap-y-2' : 'gap-x-5'}`, children: [_jsx(SettingsButton, {}), bottomMenuActions.map((action, index) => (_jsx(Tooltip, { content: action.tooltip, theme: customTheme.tooltip, children: _jsx(Button, { onClick: action.onClick, color: "ghost", className: "rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700", children: _jsx(action.icon, { className: "text-lg text-gray-500 dark:text-gray-400" }) }) }, index)))] }));
62
76
  }
@@ -0,0 +1,33 @@
1
+ /** Where the account surface lives, on whichever app hosts it. */
2
+ export declare const ACCOUNT_SETTINGS_PATH = "/settings";
3
+ interface AccountSettingsLinkBase {
4
+ isOpening: boolean;
5
+ /** Name of the app hosting the account surface, e.g. "RoboSystems". */
6
+ hostAppName: string;
7
+ /** Tooltip / accessible description, honest about leaving the app. */
8
+ description: string;
9
+ }
10
+ /**
11
+ * Exactly one of `href` / `open` is meaningful, and the discriminant says
12
+ * which — so a caller can't render a link to a destination that needs an
13
+ * SSO handoff, or a button where a plain anchor belongs.
14
+ */
15
+ export type AccountSettingsLink = AccountSettingsLinkBase & ({
16
+ isCrossApp: false;
17
+ href: string;
18
+ open?: never;
19
+ } | {
20
+ isCrossApp: true;
21
+ href?: never;
22
+ open: () => Promise<boolean>;
23
+ });
24
+ /**
25
+ * Resolve how this app should reach account settings.
26
+ *
27
+ * Identity, password, passkeys and API keys are account-global and live on
28
+ * one app — the login home. On that app the cog is an ordinary link; on the
29
+ * product apps it's an SSO handoff into a new tab, so the user's place here
30
+ * (a half-written journal entry, a loaded report) survives the detour.
31
+ */
32
+ export declare function useAccountSettingsLink(apiUrl?: string): AccountSettingsLink;
33
+ export {};
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+ import { getLoginHomeName, isLoginHome } from '../../auth-core/config';
3
+ import { useCrossAppLink } from '../../hooks/use-cross-app-link';
4
+ /** Where the account surface lives, on whichever app hosts it. */
5
+ export const ACCOUNT_SETTINGS_PATH = '/settings';
6
+ /**
7
+ * Resolve how this app should reach account settings.
8
+ *
9
+ * Identity, password, passkeys and API keys are account-global and live on
10
+ * one app — the login home. On that app the cog is an ordinary link; on the
11
+ * product apps it's an SSO handoff into a new tab, so the user's place here
12
+ * (a half-written journal entry, a loaded report) survives the detour.
13
+ */
14
+ export function useAccountSettingsLink(apiUrl) {
15
+ const { openLoginHome, isOpening } = useCrossAppLink(apiUrl);
16
+ const hostAppName = getLoginHomeName();
17
+ if (isLoginHome()) {
18
+ return {
19
+ isCrossApp: false,
20
+ href: ACCOUNT_SETTINGS_PATH,
21
+ isOpening,
22
+ hostAppName,
23
+ description: 'Settings',
24
+ };
25
+ }
26
+ return {
27
+ isCrossApp: true,
28
+ open: () => openLoginHome({ path: ACCOUNT_SETTINGS_PATH, target: '_blank' }),
29
+ isOpening,
30
+ hostAppName,
31
+ description: `Settings (opens ${hostAppName} in a new tab)`,
32
+ };
33
+ }
@@ -1,3 +1,4 @@
1
+ export { ACCOUNT_SETTINGS_PATH, useAccountSettingsLink, type AccountSettingsLink, } from './account-settings';
1
2
  export { CoreNavbar } from './CoreNavbar';
2
3
  export { CoreSidebar, type SidebarItemData } from './CoreSidebar';
3
4
  export { LandingFooter, type FooterLink, type LandingFooterProps, } from './LandingFooter';
@@ -1,3 +1,4 @@
1
+ export { ACCOUNT_SETTINGS_PATH, useAccountSettingsLink, } from './account-settings';
1
2
  export { CoreNavbar } from './CoreNavbar';
2
3
  export { CoreSidebar } from './CoreSidebar';
3
4
  export { LandingFooter, } from './LandingFooter';