@calimero-network/mero-react 4.0.0 → 4.1.0

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/index.d.cts CHANGED
@@ -274,11 +274,17 @@ interface LoginModalProps {
274
274
  isOpen: boolean;
275
275
  /** Theme overrides — accepts any subset of `MeroTheme` tokens */
276
276
  theme?: MeroTheme;
277
+ /**
278
+ * Ports probed when discovering remote/local nodes. Defaults to the
279
+ * well-known Calimero dev ports (2428, 2429, 2528, 2529). Mostly an escape
280
+ * hatch for non-standard setups and tests.
281
+ */
282
+ localNodePorts?: readonly number[];
277
283
  }
278
284
  /**
279
285
  * LoginModal - Connection modal component
280
286
  */
281
- declare function LoginModal({ onConnect, onClose, connectionType, isOpen, theme, }: LoginModalProps): React.ReactPortal | null;
287
+ declare function LoginModal({ onConnect, onClose, connectionType, isOpen, theme, localNodePorts, }: LoginModalProps): React.ReactPortal | null;
282
288
 
283
289
  interface CalimeroLogoProps extends React__default.SVGAttributes<SVGSVGElement> {
284
290
  /** Width / height shorthand (applied to both). Default: 24. */
@@ -316,6 +322,54 @@ interface MigrationAdminPanelProps {
316
322
  */
317
323
  declare function MigrationAdminPanel({ namespaceId, pollIntervalMs, className }: MigrationAdminPanelProps): react_jsx_runtime.JSX.Element;
318
324
 
325
+ /**
326
+ * Local-node discovery.
327
+ *
328
+ * Calimero dev/desktop nodes expose a public, unauthenticated health endpoint
329
+ * at `GET {nodeUrl}/admin-api/health` that returns `{ "data": { "status":
330
+ * "alive" } }`. The most common local layouts run on a small, well-known set of
331
+ * ports, so instead of forcing the user to type a URL we probe those ports and
332
+ * offer whatever is actually running.
333
+ */
334
+ /**
335
+ * Default ports probed when discovering local nodes. Covers the two-node dev
336
+ * stacks used across the Calimero apps (RPC + alt ports for node1 / node2).
337
+ */
338
+ declare const DEFAULT_LOCAL_NODE_PORTS: readonly [2428, 2429, 2528, 2529];
339
+ interface DiscoverLocalNodesOptions {
340
+ /** Ports to probe. Defaults to {@link DEFAULT_LOCAL_NODE_PORTS}. */
341
+ ports?: readonly number[];
342
+ /** Per-probe timeout in ms. Defaults to 2000. */
343
+ timeoutMs?: number;
344
+ /** Abort the whole discovery (e.g. when the modal closes). */
345
+ signal?: AbortSignal;
346
+ }
347
+ /** Build the canonical base URL for a local node on the given port. */
348
+ declare function localNodeUrl(port: number): string;
349
+ /**
350
+ * Resolve an API `path` against a node `baseUrl`. Ensures the base ends with a
351
+ * slash first, so a base that carries a path prefix (e.g. a `NODE_PATH_PREFIX`
352
+ * deployment like `http://host/node1`) keeps that segment instead of having it
353
+ * stripped by relative URL resolution.
354
+ */
355
+ declare function nodeEndpoint(baseUrl: string, path: string): string;
356
+ /**
357
+ * Probe a single node's health endpoint. Resolves `true` only when the node
358
+ * answers with a 2xx and (when the body is JSON) reports a non-dead status.
359
+ * Any network error, timeout, or non-ok response resolves `false` — never
360
+ * throws (except on an external abort, which is swallowed as `false`).
361
+ */
362
+ declare function probeNodeHealth(baseUrl: string, options?: {
363
+ timeoutMs?: number;
364
+ signal?: AbortSignal;
365
+ }): Promise<boolean>;
366
+ /**
367
+ * Probe the configured local ports in parallel and return the base URLs that
368
+ * responded as healthy, in the same order as the `ports` argument (which
369
+ * defaults to ascending). Returns an empty array when nothing local is running.
370
+ */
371
+ declare function discoverLocalNodes(options?: DiscoverLocalNodesOptions): Promise<string[]>;
372
+
319
373
  /**
320
374
  * Shape accepted by the metadata-setter hooks — re-exported from mero-js so
321
375
  * callers have one canonical type. A `set*Metadata` call **replaces the whole
@@ -734,4 +788,4 @@ declare function clearContextIdentity(): void;
734
788
  */
735
789
  declare function clearAllStorage(): void;
736
790
 
737
- export { type AppContext, AppMode, type ApplicationContextRecord, CalimeroLogo, type CalimeroLogoProps, ConnectButton, type ConnectButtonProps, ConnectionType, type ContextDiscoveryOptions, type ContextDiscoveryState, type CustomConnectionConfig, type ExecutionResult, LoginModal, type LoginModalProps, MERO_CSS_VARS, MeroContext, type MeroContextValue, MeroProvider, type MeroProviderConfig, type MeroProviderProps, type MeroTheme, MigrationAdminPanel, type MigrationAdminPanelProps, MigrationPendingBanner, type MigrationPendingBannerProps, type ResolvedMeroTheme, type SetMetadataInput, clearAllStorage, clearApplicationId, clearContextId, clearContextIdentity, clearNodeUrl, cssVar, defaultMeroTheme, getApplicationId, getContextId, getContextIdentity, getNodeUrl, localStorageTokenStorage, resolveMeroTheme, setApplicationId, setContextId, setContextIdentity, setNodeUrl, themeToCssVars, useAddGroupMembers, useAppVersion, useApplicationContexts, useContextDiscovery, useContextGroup, useContexts, useCreateContext, useCreateGroupInNamespace, useCreateNamespace, useCreateNamespaceInvitation, useDefaultCapabilities, useDeleteContext, useDeleteGroup, useDeleteNamespace, useDetachContextFromGroup, useExecute, useGroupCapabilities, useGroupContexts, useGroupInfo, useGroupInvitations, useGroupMembers, useGroupMetadata, useGroupUpgradeStatus, useJoinContext, useJoinGroup, useJoinNamespace, useJoinSubgroupInheritance, useLatestVersion, useMemberMetadata, useMero, useMigrationStatus, useMyAuthoredMigration, useNamespace, useNamespaceGroups, useNamespaceIdentity, useNamespaces, useNamespacesForApplication, useRegisterGroupSigningKey, useRemoveGroupMembers, useReparentGroup, useResyncContext, useRetryGroupUpgrade, useSetContextMetadata, useSetDefaultCapabilities, useSetGroupMetadata, useSetMemberMetadata, useSetSubgroupVisibility, useSetTeeAdmissionPolicy, useSubgroupVisibility, useSubgroups, useSubscription, useSyncGroup, useUpdateGroupSettings, useUpdateMemberRole, useUpgradeGroup };
791
+ export { type AppContext, AppMode, type ApplicationContextRecord, CalimeroLogo, type CalimeroLogoProps, ConnectButton, type ConnectButtonProps, ConnectionType, type ContextDiscoveryOptions, type ContextDiscoveryState, type CustomConnectionConfig, DEFAULT_LOCAL_NODE_PORTS, type DiscoverLocalNodesOptions, type ExecutionResult, LoginModal, type LoginModalProps, MERO_CSS_VARS, MeroContext, type MeroContextValue, MeroProvider, type MeroProviderConfig, type MeroProviderProps, type MeroTheme, MigrationAdminPanel, type MigrationAdminPanelProps, MigrationPendingBanner, type MigrationPendingBannerProps, type ResolvedMeroTheme, type SetMetadataInput, clearAllStorage, clearApplicationId, clearContextId, clearContextIdentity, clearNodeUrl, cssVar, defaultMeroTheme, discoverLocalNodes, getApplicationId, getContextId, getContextIdentity, getNodeUrl, localNodeUrl, localStorageTokenStorage, nodeEndpoint, probeNodeHealth, resolveMeroTheme, setApplicationId, setContextId, setContextIdentity, setNodeUrl, themeToCssVars, useAddGroupMembers, useAppVersion, useApplicationContexts, useContextDiscovery, useContextGroup, useContexts, useCreateContext, useCreateGroupInNamespace, useCreateNamespace, useCreateNamespaceInvitation, useDefaultCapabilities, useDeleteContext, useDeleteGroup, useDeleteNamespace, useDetachContextFromGroup, useExecute, useGroupCapabilities, useGroupContexts, useGroupInfo, useGroupInvitations, useGroupMembers, useGroupMetadata, useGroupUpgradeStatus, useJoinContext, useJoinGroup, useJoinNamespace, useJoinSubgroupInheritance, useLatestVersion, useMemberMetadata, useMero, useMigrationStatus, useMyAuthoredMigration, useNamespace, useNamespaceGroups, useNamespaceIdentity, useNamespaces, useNamespacesForApplication, useRegisterGroupSigningKey, useRemoveGroupMembers, useReparentGroup, useResyncContext, useRetryGroupUpgrade, useSetContextMetadata, useSetDefaultCapabilities, useSetGroupMetadata, useSetMemberMetadata, useSetSubgroupVisibility, useSetTeeAdmissionPolicy, useSubgroupVisibility, useSubgroups, useSubscription, useSyncGroup, useUpdateGroupSettings, useUpdateMemberRole, useUpgradeGroup };
package/dist/index.d.ts CHANGED
@@ -274,11 +274,17 @@ interface LoginModalProps {
274
274
  isOpen: boolean;
275
275
  /** Theme overrides — accepts any subset of `MeroTheme` tokens */
276
276
  theme?: MeroTheme;
277
+ /**
278
+ * Ports probed when discovering remote/local nodes. Defaults to the
279
+ * well-known Calimero dev ports (2428, 2429, 2528, 2529). Mostly an escape
280
+ * hatch for non-standard setups and tests.
281
+ */
282
+ localNodePorts?: readonly number[];
277
283
  }
278
284
  /**
279
285
  * LoginModal - Connection modal component
280
286
  */
281
- declare function LoginModal({ onConnect, onClose, connectionType, isOpen, theme, }: LoginModalProps): React.ReactPortal | null;
287
+ declare function LoginModal({ onConnect, onClose, connectionType, isOpen, theme, localNodePorts, }: LoginModalProps): React.ReactPortal | null;
282
288
 
283
289
  interface CalimeroLogoProps extends React__default.SVGAttributes<SVGSVGElement> {
284
290
  /** Width / height shorthand (applied to both). Default: 24. */
@@ -316,6 +322,54 @@ interface MigrationAdminPanelProps {
316
322
  */
317
323
  declare function MigrationAdminPanel({ namespaceId, pollIntervalMs, className }: MigrationAdminPanelProps): react_jsx_runtime.JSX.Element;
318
324
 
325
+ /**
326
+ * Local-node discovery.
327
+ *
328
+ * Calimero dev/desktop nodes expose a public, unauthenticated health endpoint
329
+ * at `GET {nodeUrl}/admin-api/health` that returns `{ "data": { "status":
330
+ * "alive" } }`. The most common local layouts run on a small, well-known set of
331
+ * ports, so instead of forcing the user to type a URL we probe those ports and
332
+ * offer whatever is actually running.
333
+ */
334
+ /**
335
+ * Default ports probed when discovering local nodes. Covers the two-node dev
336
+ * stacks used across the Calimero apps (RPC + alt ports for node1 / node2).
337
+ */
338
+ declare const DEFAULT_LOCAL_NODE_PORTS: readonly [2428, 2429, 2528, 2529];
339
+ interface DiscoverLocalNodesOptions {
340
+ /** Ports to probe. Defaults to {@link DEFAULT_LOCAL_NODE_PORTS}. */
341
+ ports?: readonly number[];
342
+ /** Per-probe timeout in ms. Defaults to 2000. */
343
+ timeoutMs?: number;
344
+ /** Abort the whole discovery (e.g. when the modal closes). */
345
+ signal?: AbortSignal;
346
+ }
347
+ /** Build the canonical base URL for a local node on the given port. */
348
+ declare function localNodeUrl(port: number): string;
349
+ /**
350
+ * Resolve an API `path` against a node `baseUrl`. Ensures the base ends with a
351
+ * slash first, so a base that carries a path prefix (e.g. a `NODE_PATH_PREFIX`
352
+ * deployment like `http://host/node1`) keeps that segment instead of having it
353
+ * stripped by relative URL resolution.
354
+ */
355
+ declare function nodeEndpoint(baseUrl: string, path: string): string;
356
+ /**
357
+ * Probe a single node's health endpoint. Resolves `true` only when the node
358
+ * answers with a 2xx and (when the body is JSON) reports a non-dead status.
359
+ * Any network error, timeout, or non-ok response resolves `false` — never
360
+ * throws (except on an external abort, which is swallowed as `false`).
361
+ */
362
+ declare function probeNodeHealth(baseUrl: string, options?: {
363
+ timeoutMs?: number;
364
+ signal?: AbortSignal;
365
+ }): Promise<boolean>;
366
+ /**
367
+ * Probe the configured local ports in parallel and return the base URLs that
368
+ * responded as healthy, in the same order as the `ports` argument (which
369
+ * defaults to ascending). Returns an empty array when nothing local is running.
370
+ */
371
+ declare function discoverLocalNodes(options?: DiscoverLocalNodesOptions): Promise<string[]>;
372
+
319
373
  /**
320
374
  * Shape accepted by the metadata-setter hooks — re-exported from mero-js so
321
375
  * callers have one canonical type. A `set*Metadata` call **replaces the whole
@@ -734,4 +788,4 @@ declare function clearContextIdentity(): void;
734
788
  */
735
789
  declare function clearAllStorage(): void;
736
790
 
737
- export { type AppContext, AppMode, type ApplicationContextRecord, CalimeroLogo, type CalimeroLogoProps, ConnectButton, type ConnectButtonProps, ConnectionType, type ContextDiscoveryOptions, type ContextDiscoveryState, type CustomConnectionConfig, type ExecutionResult, LoginModal, type LoginModalProps, MERO_CSS_VARS, MeroContext, type MeroContextValue, MeroProvider, type MeroProviderConfig, type MeroProviderProps, type MeroTheme, MigrationAdminPanel, type MigrationAdminPanelProps, MigrationPendingBanner, type MigrationPendingBannerProps, type ResolvedMeroTheme, type SetMetadataInput, clearAllStorage, clearApplicationId, clearContextId, clearContextIdentity, clearNodeUrl, cssVar, defaultMeroTheme, getApplicationId, getContextId, getContextIdentity, getNodeUrl, localStorageTokenStorage, resolveMeroTheme, setApplicationId, setContextId, setContextIdentity, setNodeUrl, themeToCssVars, useAddGroupMembers, useAppVersion, useApplicationContexts, useContextDiscovery, useContextGroup, useContexts, useCreateContext, useCreateGroupInNamespace, useCreateNamespace, useCreateNamespaceInvitation, useDefaultCapabilities, useDeleteContext, useDeleteGroup, useDeleteNamespace, useDetachContextFromGroup, useExecute, useGroupCapabilities, useGroupContexts, useGroupInfo, useGroupInvitations, useGroupMembers, useGroupMetadata, useGroupUpgradeStatus, useJoinContext, useJoinGroup, useJoinNamespace, useJoinSubgroupInheritance, useLatestVersion, useMemberMetadata, useMero, useMigrationStatus, useMyAuthoredMigration, useNamespace, useNamespaceGroups, useNamespaceIdentity, useNamespaces, useNamespacesForApplication, useRegisterGroupSigningKey, useRemoveGroupMembers, useReparentGroup, useResyncContext, useRetryGroupUpgrade, useSetContextMetadata, useSetDefaultCapabilities, useSetGroupMetadata, useSetMemberMetadata, useSetSubgroupVisibility, useSetTeeAdmissionPolicy, useSubgroupVisibility, useSubgroups, useSubscription, useSyncGroup, useUpdateGroupSettings, useUpdateMemberRole, useUpgradeGroup };
791
+ export { type AppContext, AppMode, type ApplicationContextRecord, CalimeroLogo, type CalimeroLogoProps, ConnectButton, type ConnectButtonProps, ConnectionType, type ContextDiscoveryOptions, type ContextDiscoveryState, type CustomConnectionConfig, DEFAULT_LOCAL_NODE_PORTS, type DiscoverLocalNodesOptions, type ExecutionResult, LoginModal, type LoginModalProps, MERO_CSS_VARS, MeroContext, type MeroContextValue, MeroProvider, type MeroProviderConfig, type MeroProviderProps, type MeroTheme, MigrationAdminPanel, type MigrationAdminPanelProps, MigrationPendingBanner, type MigrationPendingBannerProps, type ResolvedMeroTheme, type SetMetadataInput, clearAllStorage, clearApplicationId, clearContextId, clearContextIdentity, clearNodeUrl, cssVar, defaultMeroTheme, discoverLocalNodes, getApplicationId, getContextId, getContextIdentity, getNodeUrl, localNodeUrl, localStorageTokenStorage, nodeEndpoint, probeNodeHealth, resolveMeroTheme, setApplicationId, setContextId, setContextIdentity, setNodeUrl, themeToCssVars, useAddGroupMembers, useAppVersion, useApplicationContexts, useContextDiscovery, useContextGroup, useContexts, useCreateContext, useCreateGroupInNamespace, useCreateNamespace, useCreateNamespaceInvitation, useDefaultCapabilities, useDeleteContext, useDeleteGroup, useDeleteNamespace, useDetachContextFromGroup, useExecute, useGroupCapabilities, useGroupContexts, useGroupInfo, useGroupInvitations, useGroupMembers, useGroupMetadata, useGroupUpgradeStatus, useJoinContext, useJoinGroup, useJoinNamespace, useJoinSubgroupInheritance, useLatestVersion, useMemberMetadata, useMero, useMigrationStatus, useMyAuthoredMigration, useNamespace, useNamespaceGroups, useNamespaceIdentity, useNamespaces, useNamespacesForApplication, useRegisterGroupSigningKey, useRemoveGroupMembers, useReparentGroup, useResyncContext, useRetryGroupUpgrade, useSetContextMetadata, useSetDefaultCapabilities, useSetGroupMetadata, useSetMemberMetadata, useSetSubgroupVisibility, useSetTeeAdmissionPolicy, useSubgroupVisibility, useSubgroups, useSubscription, useSyncGroup, useUpdateGroupSettings, useUpdateMemberRole, useUpgradeGroup };
package/dist/index.js CHANGED
@@ -473,6 +473,59 @@ function CalimeroLogo({
473
473
  );
474
474
  }
475
475
 
476
+ // src/utils/nodeDiscovery.ts
477
+ var DEFAULT_LOCAL_NODE_PORTS = [2428, 2429, 2528, 2529];
478
+ var LOCAL_HOST = "localhost";
479
+ var DEFAULT_PROBE_TIMEOUT_MS = 2e3;
480
+ function localNodeUrl(port) {
481
+ return `http://${LOCAL_HOST}:${port}`;
482
+ }
483
+ function nodeEndpoint(baseUrl, path) {
484
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
485
+ return new URL(path, base).toString();
486
+ }
487
+ async function probeNodeHealth(baseUrl, options = {}) {
488
+ const { timeoutMs = DEFAULT_PROBE_TIMEOUT_MS, signal } = options;
489
+ if (signal?.aborted) return false;
490
+ const controller = new AbortController();
491
+ const onAbort = () => controller.abort();
492
+ const listenerAdded = !!signal;
493
+ if (listenerAdded) signal.addEventListener("abort", onAbort, { once: true });
494
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
495
+ try {
496
+ const url = nodeEndpoint(baseUrl, "admin-api/health");
497
+ const res = await fetch(url, {
498
+ method: "GET",
499
+ signal: controller.signal
500
+ });
501
+ if (!res.ok) return false;
502
+ try {
503
+ const body = await res.json();
504
+ const status = body?.data?.status !== void 0 ? body?.data?.status : body?.status;
505
+ if (status === void 0) return true;
506
+ return typeof status === "string" && status.toLowerCase() === "alive";
507
+ } catch {
508
+ return true;
509
+ }
510
+ } catch {
511
+ return false;
512
+ } finally {
513
+ clearTimeout(timer);
514
+ if (listenerAdded) signal.removeEventListener("abort", onAbort);
515
+ }
516
+ }
517
+ async function discoverLocalNodes(options = {}) {
518
+ const { ports = DEFAULT_LOCAL_NODE_PORTS, timeoutMs, signal } = options;
519
+ const results = await Promise.all(
520
+ ports.map(async (port) => {
521
+ const url = localNodeUrl(port);
522
+ const ok = await probeNodeHealth(url, { timeoutMs, signal });
523
+ return ok ? url : null;
524
+ })
525
+ );
526
+ return results.filter((url) => url !== null);
527
+ }
528
+
476
529
  // src/theme.ts
477
530
  var defaultMeroTheme = Object.freeze({
478
531
  primary: "#a5ff11",
@@ -521,6 +574,8 @@ function themeToCssVars(theme) {
521
574
  function cssVar(theme, key) {
522
575
  return `var(${MERO_CSS_VARS[key]}, ${theme[key]})`;
523
576
  }
577
+ var DEFAULT_LOCAL_NODE_URL = "http://node1.127.0.0.1.nip.io";
578
+ var CUSTOM_SELECTION = "__custom__";
524
579
  function isValidUrl(urlString) {
525
580
  if (!urlString || urlString.trim() === "") {
526
581
  return false;
@@ -549,6 +604,9 @@ function isValidUrl(urlString) {
549
604
  return false;
550
605
  }
551
606
  }
607
+ function displayNodeUrl(url) {
608
+ return url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
609
+ }
552
610
  function tint(color, percent) {
553
611
  return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
554
612
  }
@@ -653,10 +711,60 @@ function buildStyles(t) {
653
711
  transition: "all 0.15s ease"
654
712
  },
655
713
  radioLabelActive: {
656
- borderColor: accent,
714
+ border: `1px solid ${accent}`,
715
+ backgroundColor: accentGlow,
716
+ color: text
717
+ },
718
+ radioList: {
719
+ display: "flex",
720
+ flexDirection: "column",
721
+ gap: "0.5rem",
722
+ marginBottom: "1rem"
723
+ },
724
+ radioItem: {
725
+ display: "flex",
726
+ alignItems: "center",
727
+ gap: "0.625rem",
728
+ color: text,
729
+ cursor: "pointer",
730
+ padding: "0.75rem 1rem",
731
+ borderRadius: radius,
732
+ border: `1px solid ${border}`,
733
+ backgroundColor: bgSecondary,
734
+ transition: "all 0.15s ease",
735
+ fontSize: "0.875rem"
736
+ },
737
+ radioItemActive: {
738
+ // Override the full `border` shorthand (not just borderColor) so React
739
+ // never has to mix shorthand + longhand on the same element.
740
+ border: `1px solid ${accent}`,
657
741
  backgroundColor: accentGlow,
658
742
  color: text
659
743
  },
744
+ radioIndicator: {
745
+ flexShrink: 0,
746
+ width: "1rem",
747
+ height: "1rem",
748
+ borderRadius: "50%",
749
+ border: `2px solid ${border}`,
750
+ display: "flex",
751
+ alignItems: "center",
752
+ justifyContent: "center"
753
+ },
754
+ radioIndicatorActive: {
755
+ border: `2px solid ${accent}`
756
+ },
757
+ radioDot: {
758
+ width: "0.5rem",
759
+ height: "0.5rem",
760
+ borderRadius: "50%",
761
+ backgroundColor: accent
762
+ },
763
+ nodeMeta: {
764
+ marginLeft: "auto",
765
+ fontSize: "0.75rem",
766
+ color: textSecondary
767
+ },
660
768
  input: {
661
769
  width: "100%",
662
770
  padding: "0.75rem 1rem",
@@ -682,6 +790,29 @@ function buildStyles(t) {
682
790
  localInfoCode: {
683
791
  color: accent
684
792
  },
793
+ noNodeInfo: {
794
+ color: textSecondary,
795
+ fontSize: "0.875rem",
796
+ textAlign: "center",
797
+ padding: "0.75rem",
798
+ backgroundColor: bgSecondary,
799
+ borderRadius: radius,
800
+ marginBottom: "1rem",
801
+ border: `1px solid ${border}`
802
+ },
803
+ toolbar: {
804
+ display: "flex",
805
+ justifyContent: "center",
806
+ marginBottom: "1rem"
807
+ },
808
+ rescan: {
809
+ background: "none",
810
+ border: "none",
811
+ color: accent,
812
+ cursor: "pointer",
813
+ fontSize: "0.8125rem",
814
+ padding: "0.25rem 0.5rem"
815
+ },
685
816
  buttonGroup: {
686
817
  display: "flex",
687
818
  justifyContent: "center"
@@ -709,6 +840,15 @@ function buildStyles(t) {
709
840
  padding: "2rem",
710
841
  color: textSecondary
711
842
  },
843
+ discovering: {
844
+ display: "flex",
845
+ flexDirection: "column",
846
+ alignItems: "center",
847
+ gap: "0.75rem",
848
+ padding: "1rem",
849
+ color: textSecondary,
850
+ fontSize: "0.875rem"
851
+ },
712
852
  spinner: {
713
853
  width: "2rem",
714
854
  height: "2rem",
@@ -716,6 +856,14 @@ function buildStyles(t) {
716
856
  borderTopColor: accent,
717
857
  borderRadius: "50%",
718
858
  animation: "meroSpin 1s linear infinite"
859
+ },
860
+ spinnerSmall: {
861
+ width: "1.5rem",
862
+ height: "1.5rem",
863
+ border: `3px solid ${border}`,
864
+ borderTopColor: accent,
865
+ borderRadius: "50%",
866
+ animation: "meroSpin 1s linear infinite"
719
867
  }
720
868
  };
721
869
  }
@@ -724,11 +872,14 @@ function LoginModal({
724
872
  onClose,
725
873
  connectionType,
726
874
  isOpen,
727
- theme
875
+ theme,
876
+ localNodePorts = DEFAULT_LOCAL_NODE_PORTS
728
877
  }) {
729
878
  const [nodeType, setNodeType] = useState("local");
730
- const [nodeUrl, setNodeUrl2] = useState("");
731
- const [isValid, setIsValid] = useState(true);
879
+ const [selected, setSelected] = useState(CUSTOM_SELECTION);
880
+ const [discovered, setDiscovered] = useState([]);
881
+ const [discovering, setDiscovering] = useState(false);
882
+ const [customUrl, setCustomUrl] = useState("");
732
883
  const [loading, setLoading] = useState(false);
733
884
  const [error, setError] = useState(null);
734
885
  const resolved = useMemo(() => resolveMeroTheme(theme), [theme]);
@@ -743,7 +894,7 @@ function LoginModal({
743
894
  useEffect(() => {
744
895
  const savedUrl = localStorage.getItem("mero:node_url");
745
896
  if (savedUrl) {
746
- setNodeUrl2(savedUrl);
897
+ setCustomUrl(savedUrl);
747
898
  }
748
899
  }, []);
749
900
  useEffect(() => {
@@ -753,25 +904,56 @@ function LoginModal({
753
904
  setNodeType("remote");
754
905
  }
755
906
  }, [connectionType]);
907
+ const [scanNonce, setScanNonce] = useState(0);
908
+ const portsKey = useMemo(() => localNodePorts.join(","), [localNodePorts]);
909
+ const remoteActive = isOpen && shouldShowRemote && nodeType === "remote";
756
910
  useEffect(() => {
757
- if (nodeType === "remote") {
758
- setIsValid(isValidUrl(nodeUrl));
759
- } else {
760
- setIsValid(true);
911
+ if (!remoteActive) {
912
+ return;
761
913
  }
762
- }, [nodeUrl, nodeType]);
914
+ const controller = new AbortController();
915
+ let active = true;
916
+ setDiscovering(true);
917
+ setDiscovered([]);
918
+ setError(null);
919
+ discoverLocalNodes({ ports: localNodePorts, signal: controller.signal }).then((nodes) => {
920
+ if (!active) return;
921
+ setDiscovered(nodes);
922
+ setSelected(nodes.length > 0 ? nodes[0] : CUSTOM_SELECTION);
923
+ }).catch(() => {
924
+ if (active) setSelected(CUSTOM_SELECTION);
925
+ }).finally(() => {
926
+ if (active) setDiscovering(false);
927
+ });
928
+ return () => {
929
+ active = false;
930
+ controller.abort();
931
+ };
932
+ }, [remoteActive, portsKey, scanNonce]);
933
+ const isCustom = selected === CUSTOM_SELECTION;
934
+ const hasDiscovered = discovered.length > 0;
935
+ const canConnect = !loading && (nodeType === "local" ? true : discovering ? false : isCustom ? isValidUrl(customUrl) : true);
936
+ const canConnectRef = useRef(canConnect);
937
+ canConnectRef.current = canConnect;
763
938
  const handleConnect = useCallback(async () => {
764
- if (!isValid) return;
939
+ const targetUrl = nodeType === "local" ? DEFAULT_LOCAL_NODE_URL : selected === CUSTOM_SELECTION ? customUrl : selected;
940
+ const usingDiscovered = nodeType === "remote" && selected !== CUSTOM_SELECTION;
941
+ if (nodeType === "remote" && !usingDiscovered && !isValidUrl(targetUrl)) {
942
+ return;
943
+ }
944
+ const normalizedUrl = targetUrl.replace(/\/+$/, "");
945
+ if (usingDiscovered) {
946
+ onConnect(normalizedUrl);
947
+ return;
948
+ }
765
949
  setLoading(true);
766
950
  setError(null);
767
- const baseUrl = nodeType === "local" ? "http://node1.127.0.0.1.nip.io" : nodeUrl;
768
951
  try {
769
952
  const response = await fetch(
770
- new URL("admin-api/is-authed", baseUrl).toString()
953
+ nodeEndpoint(normalizedUrl, "admin-api/is-authed")
771
954
  );
772
955
  if (response.ok || response.status === 401) {
773
956
  setLoading(false);
774
- const normalizedUrl = baseUrl.replace(/\/+$/, "");
775
957
  onConnect(normalizedUrl);
776
958
  } else {
777
959
  throw new Error(`Connection failed: ${response.statusText}`);
@@ -781,10 +963,108 @@ function LoginModal({
781
963
  setError("Failed to connect. Please check the URL and try again.");
782
964
  setLoading(false);
783
965
  }
784
- }, [isValid, nodeType, nodeUrl, onConnect]);
966
+ }, [nodeType, selected, customUrl, onConnect]);
785
967
  if (!isOpen) {
786
968
  return null;
787
969
  }
970
+ const showManualInput = isCustom;
971
+ const renderRadio = (value, label, meta) => {
972
+ const active = selected === value;
973
+ return (
974
+ // Selection is driven solely by the radio input's `onChange` — clicking
975
+ // anywhere on the wrapping label forwards to the input, and keyboard
976
+ // users can tab to / arrow through the (visually hidden but focusable)
977
+ // input. A label `onClick` here would double-fire `setSelected`.
978
+ /* @__PURE__ */ jsxs(
979
+ "label",
980
+ {
981
+ "data-testid": `node-option-${value === CUSTOM_SELECTION ? "custom" : displayNodeUrl(value)}`,
982
+ style: {
983
+ ...styles.radioItem,
984
+ ...active ? styles.radioItemActive : {}
985
+ },
986
+ children: [
987
+ /* @__PURE__ */ jsx(
988
+ "input",
989
+ {
990
+ type: "radio",
991
+ name: "mero-node",
992
+ value,
993
+ checked: active,
994
+ onChange: () => setSelected(value),
995
+ style: { position: "absolute", opacity: 0 }
996
+ }
997
+ ),
998
+ /* @__PURE__ */ jsx(
999
+ "span",
1000
+ {
1001
+ style: {
1002
+ ...styles.radioIndicator,
1003
+ ...active ? styles.radioIndicatorActive : {}
1004
+ },
1005
+ children: active && /* @__PURE__ */ jsx("span", { style: styles.radioDot })
1006
+ }
1007
+ ),
1008
+ label,
1009
+ meta && /* @__PURE__ */ jsx("span", { style: styles.nodeMeta, children: meta })
1010
+ ]
1011
+ },
1012
+ value
1013
+ )
1014
+ );
1015
+ };
1016
+ const renderRemoteView = () => {
1017
+ if (discovering) {
1018
+ return /* @__PURE__ */ jsxs("div", { style: styles.discovering, "data-testid": "node-discovering", children: [
1019
+ /* @__PURE__ */ jsx("div", { style: styles.spinnerSmall }),
1020
+ /* @__PURE__ */ jsx("p", { children: "Searching for local nodes..." })
1021
+ ] });
1022
+ }
1023
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1024
+ /* @__PURE__ */ jsx("p", { style: styles.info, children: hasDiscovered ? "Select a discovered node, or enter a node URL manually." : "No local node found. Enter a node URL to continue." }),
1025
+ hasDiscovered && /* @__PURE__ */ jsxs(
1026
+ "div",
1027
+ {
1028
+ style: styles.radioList,
1029
+ role: "radiogroup",
1030
+ "aria-label": "Available nodes",
1031
+ children: [
1032
+ discovered.map(
1033
+ (url) => renderRadio(url, displayNodeUrl(url), "local")
1034
+ ),
1035
+ renderRadio(CUSTOM_SELECTION, "Enter node URL manually")
1036
+ ]
1037
+ }
1038
+ ),
1039
+ showManualInput && /* @__PURE__ */ jsx(
1040
+ "input",
1041
+ {
1042
+ type: "text",
1043
+ value: customUrl,
1044
+ onChange: (e) => setCustomUrl(e.target.value),
1045
+ placeholder: "https://your-node-url.calimero.network",
1046
+ style: styles.input,
1047
+ "data-testid": "node-url-input",
1048
+ autoFocus: !hasDiscovered,
1049
+ onKeyDown: (e) => {
1050
+ if (e.key === "Enter" && canConnectRef.current) {
1051
+ handleConnect();
1052
+ }
1053
+ }
1054
+ }
1055
+ ),
1056
+ /* @__PURE__ */ jsx("div", { style: styles.toolbar, children: /* @__PURE__ */ jsx(
1057
+ "button",
1058
+ {
1059
+ type: "button",
1060
+ style: styles.rescan,
1061
+ onClick: () => setScanNonce((n) => n + 1),
1062
+ "data-testid": "rescan-button",
1063
+ children: "\u21BB Rescan local nodes"
1064
+ }
1065
+ ) })
1066
+ ] });
1067
+ };
788
1068
  const modalContent = /* @__PURE__ */ jsxs(Fragment, { children: [
789
1069
  /* @__PURE__ */ jsx("style", { children: `
790
1070
  @keyframes meroSpin { to { transform: rotate(360deg); } }
@@ -792,7 +1072,7 @@ function LoginModal({
792
1072
  @keyframes meroSlideIn { from { transform: translateY(-12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
793
1073
  ` }),
794
1074
  /* @__PURE__ */ jsx("div", { style: { ...themeVars, ...styles.overlay }, onClick: onClose, children: /* @__PURE__ */ jsxs("div", { style: styles.content, onClick: (e) => e.stopPropagation(), children: [
795
- /* @__PURE__ */ jsx("button", { style: styles.closeButton, onClick: onClose, children: "\xD7" }),
1075
+ /* @__PURE__ */ jsx("button", { style: styles.closeButton, onClick: onClose, "aria-label": "Close", children: "\xD7" }),
796
1076
  /* @__PURE__ */ jsxs("div", { style: styles.header, children: [
797
1077
  /* @__PURE__ */ jsx(CalimeroLogo, { size: 44, color: cssVar(resolved, "primary") }),
798
1078
  /* @__PURE__ */ jsx("h1", { style: styles.title, children: "Connect to Calimero" })
@@ -801,12 +1081,13 @@ function LoginModal({
801
1081
  /* @__PURE__ */ jsx("p", { children: "Connecting to node..." }),
802
1082
  /* @__PURE__ */ jsx("div", { style: styles.spinner })
803
1083
  ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
804
- /* @__PURE__ */ jsx("p", { style: styles.info, children: shouldShowRadioGroup ? "Select your Calimero node type to continue." : connectionType === "local" /* Local */ ? "Connect to your local Calimero node." : "Enter your remote Calimero node URL." }),
1084
+ shouldShowRadioGroup && /* @__PURE__ */ jsx("p", { style: styles.info, children: "Select your Calimero node type to continue." }),
805
1085
  error && /* @__PURE__ */ jsx("p", { style: styles.error, children: error }),
806
1086
  shouldShowRadioGroup && /* @__PURE__ */ jsxs("div", { style: styles.radioGroup, children: [
807
1087
  /* @__PURE__ */ jsxs(
808
1088
  "label",
809
1089
  {
1090
+ "data-testid": "node-type-local",
810
1091
  style: {
811
1092
  ...styles.radioLabel,
812
1093
  ...nodeType === "local" ? styles.radioLabelActive : {}
@@ -830,6 +1111,7 @@ function LoginModal({
830
1111
  /* @__PURE__ */ jsxs(
831
1112
  "label",
832
1113
  {
1114
+ "data-testid": "node-type-remote",
833
1115
  style: {
834
1116
  ...styles.radioLabel,
835
1117
  ...nodeType === "remote" ? styles.radioLabelActive : {}
@@ -851,34 +1133,22 @@ function LoginModal({
851
1133
  }
852
1134
  )
853
1135
  ] }),
854
- /* @__PURE__ */ jsx("div", { children: shouldShowRemote && nodeType === "remote" ? /* @__PURE__ */ jsx(
855
- "input",
856
- {
857
- type: "text",
858
- value: nodeUrl,
859
- onChange: (e) => setNodeUrl2(e.target.value),
860
- placeholder: "https://your-node-url.calimero.network",
861
- style: styles.input,
862
- onKeyDown: (e) => {
863
- if (e.key === "Enter" && isValid) {
864
- handleConnect();
865
- }
866
- }
867
- }
868
- ) : shouldShowLocal ? /* @__PURE__ */ jsxs("p", { style: styles.localInfo, children: [
1136
+ nodeType === "local" && shouldShowLocal && /* @__PURE__ */ jsxs("p", { style: styles.localInfo, children: [
869
1137
  "Using default local node: ",
870
1138
  /* @__PURE__ */ jsx("br", {}),
871
- /* @__PURE__ */ jsx("code", { style: styles.localInfoCode, children: "http://node1.127.0.0.1.nip.io" })
872
- ] }) : null }),
1139
+ /* @__PURE__ */ jsx("code", { style: styles.localInfoCode, children: DEFAULT_LOCAL_NODE_URL })
1140
+ ] }),
1141
+ nodeType === "remote" && shouldShowRemote && renderRemoteView(),
873
1142
  /* @__PURE__ */ jsx("div", { style: styles.buttonGroup, children: /* @__PURE__ */ jsx(
874
1143
  "button",
875
1144
  {
876
1145
  onClick: handleConnect,
877
- disabled: !isValid || loading,
1146
+ disabled: !canConnect,
878
1147
  style: {
879
1148
  ...styles.button,
880
- ...!isValid || loading ? styles.buttonDisabled : {}
1149
+ ...!canConnect ? styles.buttonDisabled : {}
881
1150
  },
1151
+ "data-testid": "connect-button",
882
1152
  children: "Connect"
883
1153
  }
884
1154
  ) })
@@ -2650,6 +2920,6 @@ function MigrationAdminPanel({ namespaceId, pollIntervalMs, className }) {
2650
2920
  ] });
2651
2921
  }
2652
2922
 
2653
- export { AppMode, CalimeroLogo, ConnectButton, ConnectionType, LoginModal, MERO_CSS_VARS, MeroContext, MeroProvider, MigrationAdminPanel, MigrationPendingBanner, clearAllStorage, clearApplicationId, clearContextId, clearContextIdentity, clearNodeUrl, cssVar, defaultMeroTheme, getApplicationId, getContextId, getContextIdentity, getNodeUrl, localStorageTokenStorage, resolveMeroTheme, setApplicationId, setContextId, setContextIdentity, setNodeUrl, themeToCssVars, useAddGroupMembers, useAppVersion, useApplicationContexts, useContextDiscovery, useContextGroup, useContexts, useCreateContext, useCreateGroupInNamespace, useCreateNamespace, useCreateNamespaceInvitation, useDefaultCapabilities, useDeleteContext, useDeleteGroup, useDeleteNamespace, useDetachContextFromGroup, useExecute, useGroupCapabilities, useGroupContexts, useGroupInfo, useGroupInvitations, useGroupMembers, useGroupMetadata, useGroupUpgradeStatus, useJoinContext, useJoinGroup, useJoinNamespace, useJoinSubgroupInheritance, useLatestVersion, useMemberMetadata, useMero, useMigrationStatus, useMyAuthoredMigration, useNamespace, useNamespaceGroups, useNamespaceIdentity, useNamespaces, useNamespacesForApplication, useRegisterGroupSigningKey, useRemoveGroupMembers, useReparentGroup, useResyncContext, useRetryGroupUpgrade, useSetContextMetadata, useSetDefaultCapabilities, useSetGroupMetadata, useSetMemberMetadata, useSetSubgroupVisibility, useSetTeeAdmissionPolicy, useSubgroupVisibility, useSubgroups, useSubscription, useSyncGroup, useUpdateGroupSettings, useUpdateMemberRole, useUpgradeGroup };
2923
+ export { AppMode, CalimeroLogo, ConnectButton, ConnectionType, DEFAULT_LOCAL_NODE_PORTS, LoginModal, MERO_CSS_VARS, MeroContext, MeroProvider, MigrationAdminPanel, MigrationPendingBanner, clearAllStorage, clearApplicationId, clearContextId, clearContextIdentity, clearNodeUrl, cssVar, defaultMeroTheme, discoverLocalNodes, getApplicationId, getContextId, getContextIdentity, getNodeUrl, localNodeUrl, localStorageTokenStorage, nodeEndpoint, probeNodeHealth, resolveMeroTheme, setApplicationId, setContextId, setContextIdentity, setNodeUrl, themeToCssVars, useAddGroupMembers, useAppVersion, useApplicationContexts, useContextDiscovery, useContextGroup, useContexts, useCreateContext, useCreateGroupInNamespace, useCreateNamespace, useCreateNamespaceInvitation, useDefaultCapabilities, useDeleteContext, useDeleteGroup, useDeleteNamespace, useDetachContextFromGroup, useExecute, useGroupCapabilities, useGroupContexts, useGroupInfo, useGroupInvitations, useGroupMembers, useGroupMetadata, useGroupUpgradeStatus, useJoinContext, useJoinGroup, useJoinNamespace, useJoinSubgroupInheritance, useLatestVersion, useMemberMetadata, useMero, useMigrationStatus, useMyAuthoredMigration, useNamespace, useNamespaceGroups, useNamespaceIdentity, useNamespaces, useNamespacesForApplication, useRegisterGroupSigningKey, useRemoveGroupMembers, useReparentGroup, useResyncContext, useRetryGroupUpgrade, useSetContextMetadata, useSetDefaultCapabilities, useSetGroupMetadata, useSetMemberMetadata, useSetSubgroupVisibility, useSetTeeAdmissionPolicy, useSubgroupVisibility, useSubgroups, useSubscription, useSyncGroup, useUpdateGroupSettings, useUpdateMemberRole, useUpgradeGroup };
2654
2924
  //# sourceMappingURL=index.js.map
2655
2925
  //# sourceMappingURL=index.js.map