@robosystems/core 0.11.3 → 0.11.5

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-core/sso.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useMemo } from 'react';
2
2
  import { RoboSystemsAuthClient } from './client';
3
3
  import { APP_CONFIGS } from './config';
4
+ import { isSafeRelativePath } from './login-home';
4
5
  // Configuration constants
5
6
  const NAVIGATION_DELAY_MS = 100; // 100ms delay for navigation
6
7
  // Debug logging helper
@@ -12,25 +13,13 @@ const debugLog = (message, error) => {
12
13
  /**
13
14
  * Reduce a `returnUrl` to a same-origin path, or reject it.
14
15
  *
15
- * The value reaches us from a query parameter (or the sessionStorage mirror of
16
- * one), so it is attacker-controlled on any crafted SSO link: an absolute URL
17
- * would bounce the freshly authenticated user to another site, and a
18
- * `javascript:` payload would execute as this origin. Cross-app returns don't
19
- * need the escape hatch — `getSSORedirectUrl` hands the path to the *target*
20
- * app, which resolves it against its own origin — so a bare absolute path is
21
- * the only shape we ever legitimately produce.
16
+ * The value reaches us from a query parameter, so it is attacker-controlled
17
+ * on any crafted link. Cross-app returns don't need anything but a bare
18
+ * absolute path — `getSSORedirectUrl` hands the path to the *target* app,
19
+ * which resolves it against its own origin. The check is the package's one
20
+ * validator, `isSafeRelativePath` (shared with the login home).
22
21
  */
23
- const sanitizeReturnUrl = (returnUrl) => {
24
- if (!returnUrl)
25
- return null;
26
- // `//host` and `/\host` are both protocol-relative once a browser
27
- // normalises them, so a leading slash alone isn't enough.
28
- if (!returnUrl.startsWith('/'))
29
- return null;
30
- if (returnUrl.startsWith('//') || returnUrl.startsWith('/\\'))
31
- return null;
32
- return returnUrl;
33
- };
22
+ const sanitizeReturnUrl = (returnUrl) => returnUrl && isSafeRelativePath(returnUrl) ? returnUrl : null;
34
23
  /** Keys an older version wrote; cleared on every handoff completion. */
35
24
  const LEGACY_SSO_HINT_KEYS = ['sso_target_app', 'sso_return_url'];
36
25
  const clearLegacySsoHints = () => {
@@ -4,25 +4,25 @@ export interface ToastMessage {
4
4
  message: string;
5
5
  duration?: number;
6
6
  }
7
+ /** Test-only: clear the shared store between tests. */
8
+ export declare function resetToastsForTests(): void;
9
+ /** Renders the shared toasts, but only in the first mounted container. */
10
+ declare function SharedToastContainer(): import("react").JSX.Element;
7
11
  /**
8
- * A reusable toast notification hook built on Flowbite React components.
12
+ * Toast notifications on Flowbite React, shared across the app.
9
13
  *
10
- * Provides toast notifications with auto-dismiss functionality and consistent styling
11
- * across the RoboSystems ecosystem.
14
+ * Every call raises into one store, and the first mounted `ToastContainer`
15
+ * renders it, so a component can raise a toast without rendering a
16
+ * container of its own, and two containers never show a toast twice.
12
17
  *
13
18
  * @example
14
19
  * ```tsx
15
20
  * function MyComponent() {
16
- * const { showSuccess, showError, ToastContainer } = useToast()
17
- *
18
- * const handleSuccess = () => {
19
- * showSuccess('Operation completed successfully!')
20
- * }
21
- *
21
+ * const { showSuccess, ToastContainer } = useToast()
22
22
  * return (
23
23
  * <>
24
24
  * <ToastContainer />
25
- * <button onClick={handleSuccess}>Show Success Toast</button>
25
+ * <button onClick={() => showSuccess('Saved')}>Save</button>
26
26
  * </>
27
27
  * )
28
28
  * }
@@ -33,5 +33,6 @@ export declare function useToast(): {
33
33
  showError: (message: string, duration?: number) => void;
34
34
  showWarning: (message: string, duration?: number) => void;
35
35
  showInfo: (message: string, duration?: number) => void;
36
- ToastContainer: () => import("react").JSX.Element;
36
+ ToastContainer: typeof SharedToastContainer;
37
37
  };
38
+ export {};
@@ -1,117 +1,117 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Toast, ToastToggle } from 'flowbite-react';
4
- import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useCallback, useEffect, useId, useSyncExternalStore } from 'react';
5
5
  import { HiCheckCircle, HiExclamation, HiInformationCircle, } from 'react-icons/hi';
6
+ let toasts = [];
7
+ let hosts = [];
8
+ const listeners = new Set();
9
+ const timeouts = new Map();
10
+ const EMPTY = [];
11
+ function emit() {
12
+ listeners.forEach((listener) => listener());
13
+ }
14
+ function subscribe(listener) {
15
+ listeners.add(listener);
16
+ return () => {
17
+ listeners.delete(listener);
18
+ };
19
+ }
20
+ function removeToast(id) {
21
+ const timeout = timeouts.get(id);
22
+ if (timeout) {
23
+ clearTimeout(timeout);
24
+ timeouts.delete(id);
25
+ }
26
+ toasts = toasts.filter((t) => t.id !== id);
27
+ emit();
28
+ }
29
+ function addToast(toast) {
30
+ const id = Math.random().toString(36).substring(2, 11);
31
+ const newToast = Object.assign({ id, duration: 5000 }, toast);
32
+ toasts = [...toasts, newToast];
33
+ emit();
34
+ if (newToast.duration && newToast.duration > 0) {
35
+ timeouts.set(id, setTimeout(() => removeToast(id), newToast.duration));
36
+ }
37
+ }
38
+ /** Test-only: clear the shared store between tests. */
39
+ export function resetToastsForTests() {
40
+ timeouts.forEach(clearTimeout);
41
+ timeouts.clear();
42
+ toasts = [];
43
+ hosts = [];
44
+ emit();
45
+ }
46
+ const ICONS = {
47
+ success: HiCheckCircle,
48
+ error: HiExclamation,
49
+ warning: HiExclamation,
50
+ info: HiInformationCircle,
51
+ };
52
+ const COLORS = {
53
+ success: 'success',
54
+ error: 'failure',
55
+ warning: 'warning',
56
+ info: 'info',
57
+ };
58
+ const ICON_CLASSES = {
59
+ success: 'bg-green-100 text-green-500 dark:bg-green-800 dark:text-green-200',
60
+ error: 'bg-red-100 text-red-500 dark:bg-red-800 dark:text-red-200',
61
+ warning: 'bg-orange-100 text-orange-500 dark:bg-orange-700 dark:text-orange-200',
62
+ info: 'bg-primary-100 text-primary-500 dark:bg-primary-800 dark:text-primary-200',
63
+ };
64
+ /** Renders the shared toasts, but only in the first mounted container. */
65
+ function SharedToastContainer() {
66
+ const hostId = useId();
67
+ useEffect(() => {
68
+ hosts = [...hosts, hostId];
69
+ emit();
70
+ return () => {
71
+ hosts = hosts.filter((h) => h !== hostId);
72
+ emit();
73
+ };
74
+ }, [hostId]);
75
+ const current = useSyncExternalStore(subscribe, () => toasts, () => EMPTY);
76
+ const isHost = useSyncExternalStore(subscribe, () => hosts[0] === hostId, () => false);
77
+ if (!isHost)
78
+ return null;
79
+ return (_jsx("div", { className: "fixed top-4 right-4 z-50 flex flex-col gap-2", children: current.map((toast) => {
80
+ var _a;
81
+ const Icon = (_a = ICONS[toast.type]) !== null && _a !== void 0 ? _a : HiInformationCircle;
82
+ return (_jsxs(Toast, { className: "min-w-80", color: COLORS[toast.type], children: [_jsx("div", { className: `inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${ICON_CLASSES[toast.type]}`, children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsx("div", { className: "ml-3 text-sm font-normal", children: toast.message }), _jsx(ToastToggle, { onClick: () => removeToast(toast.id) })] }, toast.id));
83
+ }) }));
84
+ }
6
85
  /**
7
- * A reusable toast notification hook built on Flowbite React components.
86
+ * Toast notifications on Flowbite React, shared across the app.
8
87
  *
9
- * Provides toast notifications with auto-dismiss functionality and consistent styling
10
- * across the RoboSystems ecosystem.
88
+ * Every call raises into one store, and the first mounted `ToastContainer`
89
+ * renders it, so a component can raise a toast without rendering a
90
+ * container of its own, and two containers never show a toast twice.
11
91
  *
12
92
  * @example
13
93
  * ```tsx
14
94
  * function MyComponent() {
15
- * const { showSuccess, showError, ToastContainer } = useToast()
16
- *
17
- * const handleSuccess = () => {
18
- * showSuccess('Operation completed successfully!')
19
- * }
20
- *
95
+ * const { showSuccess, ToastContainer } = useToast()
21
96
  * return (
22
97
  * <>
23
98
  * <ToastContainer />
24
- * <button onClick={handleSuccess}>Show Success Toast</button>
99
+ * <button onClick={() => showSuccess('Saved')}>Save</button>
25
100
  * </>
26
101
  * )
27
102
  * }
28
103
  * ```
29
104
  */
30
105
  export function useToast() {
31
- const [toasts, setToasts] = useState([]);
32
- const timeoutRefs = useRef({});
33
- const addToast = useCallback((toast) => {
34
- const id = Math.random().toString(36).substring(2, 11);
35
- const newToast = Object.assign({ id, duration: 5000 }, toast);
36
- setToasts((prev) => [...prev, newToast]);
37
- if (newToast.duration && newToast.duration > 0) {
38
- const timeoutId = setTimeout(() => {
39
- setToasts((prev) => prev.filter((t) => t.id !== id));
40
- delete timeoutRefs.current[id];
41
- }, newToast.duration);
42
- timeoutRefs.current[id] = timeoutId;
43
- }
44
- }, []);
45
- const removeToast = useCallback((id) => {
46
- if (timeoutRefs.current[id]) {
47
- clearTimeout(timeoutRefs.current[id]);
48
- delete timeoutRefs.current[id];
49
- }
50
- setToasts((prev) => prev.filter((t) => t.id !== id));
51
- }, []);
52
- const showSuccess = useCallback((message, duration) => {
53
- addToast({ type: 'success', message, duration });
54
- }, [addToast]);
55
- const showError = useCallback((message, duration) => {
56
- addToast({ type: 'error', message, duration });
57
- }, [addToast]);
58
- const showWarning = useCallback((message, duration) => {
59
- addToast({ type: 'warning', message, duration });
60
- }, [addToast]);
61
- const showInfo = useCallback((message, duration) => {
62
- addToast({ type: 'info', message, duration });
63
- }, [addToast]);
64
- // Cleanup all timeouts on unmount
65
- useEffect(() => {
66
- const timeouts = timeoutRefs.current;
67
- return () => {
68
- Object.values(timeouts).forEach(clearTimeout);
69
- };
70
- }, []);
71
- const ToastContainer = useCallback(() => (_jsx("div", { className: "fixed top-4 right-4 z-50 flex flex-col gap-2", children: toasts.map((toast) => {
72
- const getIcon = () => {
73
- switch (toast.type) {
74
- case 'success':
75
- return HiCheckCircle;
76
- case 'error':
77
- return HiExclamation;
78
- case 'warning':
79
- return HiExclamation;
80
- case 'info':
81
- return HiInformationCircle;
82
- default:
83
- return HiInformationCircle;
84
- }
85
- };
86
- const getColor = () => {
87
- switch (toast.type) {
88
- case 'success':
89
- return 'success';
90
- case 'error':
91
- return 'failure';
92
- case 'warning':
93
- return 'warning';
94
- case 'info':
95
- return 'info';
96
- default:
97
- return 'info';
98
- }
99
- };
100
- const Icon = getIcon();
101
- const color = getColor();
102
- return (_jsxs(Toast, { className: "min-w-80", color: color, children: [_jsx("div", { className: `inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${toast.type === 'success'
103
- ? 'bg-green-100 text-green-500 dark:bg-green-800 dark:text-green-200'
104
- : toast.type === 'error'
105
- ? 'bg-red-100 text-red-500 dark:bg-red-800 dark:text-red-200'
106
- : toast.type === 'warning'
107
- ? 'bg-orange-100 text-orange-500 dark:bg-orange-700 dark:text-orange-200'
108
- : 'bg-primary-100 text-primary-500 dark:bg-primary-800 dark:text-primary-200'}`, children: _jsx(Icon, { className: "h-5 w-5" }) }), _jsx("div", { className: "ml-3 text-sm font-normal", children: toast.message }), _jsx(ToastToggle, { onClick: () => removeToast(toast.id) })] }, toast.id));
109
- }) })), [toasts, removeToast]);
106
+ const showSuccess = useCallback((message, duration) => addToast({ type: 'success', message, duration }), []);
107
+ const showError = useCallback((message, duration) => addToast({ type: 'error', message, duration }), []);
108
+ const showWarning = useCallback((message, duration) => addToast({ type: 'warning', message, duration }), []);
109
+ const showInfo = useCallback((message, duration) => addToast({ type: 'info', message, duration }), []);
110
110
  return {
111
111
  showSuccess,
112
112
  showError,
113
113
  showWarning,
114
114
  showInfo,
115
- ToastContainer,
115
+ ToastContainer: SharedToastContainer,
116
116
  };
117
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robosystems/core",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
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,