@robosystems/core 0.8.1 → 0.8.3
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/auth-components/SignInForm.d.ts +14 -0
- package/auth-components/SignInForm.js +32 -0
- package/auth-core/client.js +0 -2
- package/auth-core/config.d.ts +2 -0
- package/auth-core/config.js +2 -0
- package/auth-core/sso.d.ts +15 -1
- package/auth-core/sso.js +13 -9
- package/hooks/index.d.ts +1 -0
- package/hooks/index.js +1 -0
- package/hooks/use-cross-app-link.d.ts +38 -0
- package/hooks/use-cross-app-link.js +56 -0
- package/package.json +1 -1
- package/ui-components/layout/CoreNavbar.js +8 -4
- package/ui-components/layout/CoreSidebar.js +15 -1
- package/ui-components/layout/account-settings.d.ts +33 -0
- package/ui-components/layout/account-settings.js +33 -0
- package/ui-components/layout/index.d.ts +1 -0
- package/ui-components/layout/index.js +1 -0
|
@@ -17,6 +17,20 @@ export interface SignInFormProps {
|
|
|
17
17
|
* auth body) stays "Invalid email or password"; 5xx gets its own message.
|
|
18
18
|
*/
|
|
19
19
|
export declare function loginErrorMessage(error: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* Message for a login that verified the password but did not mint a session.
|
|
22
|
+
*
|
|
23
|
+
* The backend interposes a passkey step between password check and session
|
|
24
|
+
* (`mfa_required` for an enrolled user, `mfa_enrollment_required` for a
|
|
25
|
+
* privileged one under enforcement). This form implements no such step, so
|
|
26
|
+
* the flow ends here — and it must end *visibly*. Navigating on a sessionless
|
|
27
|
+
* response drops the user on a protected route that bounces them straight
|
|
28
|
+
* back to the login page, an unexplained loop.
|
|
29
|
+
*
|
|
30
|
+
* Deployments that enable passkeys need a login surface that carries the
|
|
31
|
+
* ceremony through; see the platform app's `MfaChallenge`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function mfaUnsupportedMessage(status?: string): string;
|
|
20
34
|
/**
|
|
21
35
|
* Messages for the `?reason=` a redirect into the login page can carry. The
|
|
22
36
|
* producer is `AuthProvider`'s `logout(reason)`, which sends the user to
|
|
@@ -26,6 +26,25 @@ export function loginErrorMessage(error) {
|
|
|
26
26
|
}
|
|
27
27
|
return 'Invalid email or password';
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Message for a login that verified the password but did not mint a session.
|
|
31
|
+
*
|
|
32
|
+
* The backend interposes a passkey step between password check and session
|
|
33
|
+
* (`mfa_required` for an enrolled user, `mfa_enrollment_required` for a
|
|
34
|
+
* privileged one under enforcement). This form implements no such step, so
|
|
35
|
+
* the flow ends here — and it must end *visibly*. Navigating on a sessionless
|
|
36
|
+
* response drops the user on a protected route that bounces them straight
|
|
37
|
+
* back to the login page, an unexplained loop.
|
|
38
|
+
*
|
|
39
|
+
* Deployments that enable passkeys need a login surface that carries the
|
|
40
|
+
* ceremony through; see the platform app's `MfaChallenge`.
|
|
41
|
+
*/
|
|
42
|
+
export function mfaUnsupportedMessage(status) {
|
|
43
|
+
if (status === 'mfa_enrollment_required') {
|
|
44
|
+
return 'This account must enroll a passkey before signing in, which this sign-in page cannot complete.';
|
|
45
|
+
}
|
|
46
|
+
return 'This account requires passkey verification to sign in, which this sign-in page cannot complete.';
|
|
47
|
+
}
|
|
29
48
|
/**
|
|
30
49
|
* Messages for the `?reason=` a redirect into the login page can carry. The
|
|
31
50
|
* producer is `AuthProvider`'s `logout(reason)`, which sends the user to
|
|
@@ -125,6 +144,19 @@ export function SignInForm({ onSuccess, onRedirect, redirectTo = '/home', classN
|
|
|
125
144
|
setError('');
|
|
126
145
|
try {
|
|
127
146
|
const result = await authClient.login(formData.email, formData.password);
|
|
147
|
+
// No session was minted — the backend is waiting on a passkey step this
|
|
148
|
+
// form cannot run. Stop here rather than redirecting into a route that
|
|
149
|
+
// will bounce straight back. `success` is false only for the MFA
|
|
150
|
+
// statuses; pre-MFA backends send no `status` and read as authenticated.
|
|
151
|
+
if (!result.success) {
|
|
152
|
+
if (process.env.NODE_ENV === 'development') {
|
|
153
|
+
console.error(`[SignInForm] login returned "${result.status}" — this component does not implement the passkey step. ` +
|
|
154
|
+
'Use a sign-in surface that completes the MFA ceremony.');
|
|
155
|
+
}
|
|
156
|
+
setError(mfaUnsupportedMessage(result.status));
|
|
157
|
+
setLoading(false);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
128
160
|
// Call onSuccess callback if provided
|
|
129
161
|
if (onSuccess) {
|
|
130
162
|
onSuccess(result.user);
|
package/auth-core/client.js
CHANGED
|
@@ -579,8 +579,6 @@ export class RoboSystemsAuthClient {
|
|
|
579
579
|
async getPasskeyRegistrationOptions(proof) {
|
|
580
580
|
const response = await getPasskeyRegistrationOptions({
|
|
581
581
|
client: this.client,
|
|
582
|
-
// The proof fields land in client 1.11's generated request model;
|
|
583
|
-
// until the regen they ride as extra JSON properties, hence the cast.
|
|
584
582
|
body: {
|
|
585
583
|
mfa_token: proof === null || proof === void 0 ? void 0 : proof.mfaToken,
|
|
586
584
|
password: proof === null || proof === void 0 ? void 0 : proof.password,
|
package/auth-core/config.d.ts
CHANGED
|
@@ -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;
|
package/auth-core/config.js
CHANGED
|
@@ -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;
|
package/auth-core/sso.d.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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]);
|
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
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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';
|