diff --git a/apps/frontend/src/components/connect/ConnectList/ConnectList.tsx b/apps/frontend/src/components/connect/ConnectList/ConnectList.tsx index d83f98d2..02022ea6 100644 --- a/apps/frontend/src/components/connect/ConnectList/ConnectList.tsx +++ b/apps/frontend/src/components/connect/ConnectList/ConnectList.tsx @@ -41,9 +41,22 @@ import { } from '../../../store/rendezvousSlice'; import rendezvousReducer from '../../../store/rendezvousSlice'; import { useInjectReducer } from '../../../hooks/use-injectreducer'; -import { selectActiveProfile } from '../../../store/nodesSelectors'; +import { selectActiveProfile, selectNodeProfiles, selectIsSwitchingNode } from '../../../store/nodesSelectors'; +import { setActiveProfileId, setIsSwitching, setProfileHealth } from '../../../store/nodesSlice'; +import { clearNodeData } from '../../../store/rootSlice'; +import { clearCLNStore } from '../../../store/clnSlice'; +import { clearBKPRStore } from '../../../store/bkprSlice'; +import { clearFactoriesStore } from '../../../store/factoriesSlice'; import { fetchVouches } from '../../../services/nostr.service'; -import { RendezvousService } from '../../../services/http.service'; +import { + RendezvousService, + NodesService, + RootService, + CLNService, + BookkeeperService, + FactoriesService, +} from '../../../services/http.service'; +import logger from '../../../services/logger.service'; import JoinFactoryModal from '../JoinFactoryModal/JoinFactoryModal'; import AcceptInviteModal from '../../factories/AcceptInviteModal/AcceptInviteModal'; import ManualConnectModal from '../ManualConnectModal/ManualConnectModal'; @@ -125,6 +138,8 @@ const ConnectList = () => { const isVouchLoading = useSelector(selectVouchesLoading); const vouchErrors = useSelector(selectVouchErrors); const activeProfile = useSelector(selectActiveProfile); + const allProfiles = useSelector(selectNodeProfiles); + const isSwitchingProfile = useSelector(selectIsSwitchingNode); const enabledRelays = useSelector(selectEnabledRelays); const network = clnNetworkToCoordKey(activeProfile?.network); @@ -134,6 +149,54 @@ const ConnectList = () => { ); const activeCoordinators = useSelector(selectActiveCoords); + /* Polish 2026-05-29: profile-switch CTA for the empty state. + * When the active node's network has no coordinator binding (e.g., regtest), + * surface the user's *other* profiles that ARE covered so a one-click switch + * gets them to a working Connect view. */ + const switchCandidates = useMemo(() => { + const out: Array<{ id: string; label: string; net: CoordinatorNetwork }> = []; + for (const p of allProfiles || []) { + if (p.id === activeProfile?.id) continue; + const n = clnNetworkToCoordKey(p.network); + if (!n) continue; + out.push({ + id: p.id, + label: p.alias || p.label || p.id.slice(0, 8), + net: n, + }); + } + return out; + }, [allProfiles, activeProfile?.id]); + + const handleSwitchToProfile = useCallback(async (profileId: string) => { + if (isSwitchingProfile) return; + try { + dispatch(setIsSwitching(true)); + const result = await NodesService.switchNode(profileId); + dispatch(clearNodeData()); + dispatch(clearCLNStore()); + dispatch(clearBKPRStore()); + dispatch(clearFactoriesStore()); + dispatch(setActiveProfileId(result.profile?.id || profileId)); + await RootService.fetchRootData(); + await RootService.refreshData(); + } catch (err) { + logger.error('CTA profile switch failed:', err); + } finally { + dispatch(setIsSwitching(false)); + } + Promise.all([ + CLNService.fetchCLNData(), + BookkeeperService.fetchBKPRData(), + FactoriesService.fetchFactoriesData(), + NodesService.fetchAndDispatchNodes(), + NodesService.detectFactoryPlugin(), + NodesService.healthCheck() + .then(h => { if (h?.health) dispatch(setProfileHealth(h.health)); }) + .catch(err => logger.warn('Health check after CTA switch failed:', err)), + ]).catch(err => logger.error('Background post-CTA-switch refresh failed:', err)); + }, [dispatch, isSwitchingProfile]); + const [showAcceptInvite, setShowAcceptInvite] = useState(false); const [showManualConnect, setShowManualConnect] = useState(false); const [showSample, setShowSample] = useState(false); @@ -387,9 +450,32 @@ const ConnectList = () => { {!showSample && !network && ( - - Active node's network ({activeProfile?.network ?? 'unknown'}) is not covered by any - configured coordinator. Switch to a signet, testnet4, or mainnet node to see vouches. + +
+ Active node's network ({activeProfile?.network ?? 'unknown'}) is not covered by any + configured coordinator. +
+ {switchCandidates.length > 0 ? ( +
+ {switchCandidates.map((c) => ( + + ))} +
+ ) : ( +
+ Add a signet, testnet4, or mainnet node profile to see vouches. +
+ )}
)} {!showSample && network && activeCoordinators.length === 0 && ( diff --git a/apps/frontend/src/components/factories/AcceptInviteModal/AcceptInviteModal.tsx b/apps/frontend/src/components/factories/AcceptInviteModal/AcceptInviteModal.tsx index b5d0fa5e..0f2212a8 100644 --- a/apps/frontend/src/components/factories/AcceptInviteModal/AcceptInviteModal.tsx +++ b/apps/frontend/src/components/factories/AcceptInviteModal/AcceptInviteModal.tsx @@ -1,8 +1,30 @@ import { useEffect, useState } from 'react'; import { Modal, Button, Form, Alert, Spinner } from 'react-bootstrap'; -import { parseInviteUrl, Invite } from '../../../utilities/inviteUrl'; +import { parseInviteUrlDetailed, Invite } from '../../../utilities/inviteUrl'; import { FactoriesService } from '../../../services/http.service'; +/* Address class used by the "trust this address" gate. Public IPs mean the + * wallet's about to phone home to a stranger's box; loopback is local dev / + * regtest demo; tor is privacy-preserving; private means inside the same + * NAT (usually fine but worth surfacing). */ +function classifyAddress(addr?: string): 'tor' | 'loopback' | 'private' | 'public' | null { + if (!addr) return null; + const host = addr.split(':')[0]?.toLowerCase() ?? ''; + if (!host) return null; + if (host.endsWith('.onion')) return 'tor'; + if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'loopback'; + if ( + /^10\./.test(host) || + /^192\.168\./.test(host) || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host) || + /^169\.254\./.test(host) || + /^fe80:/.test(host) || + /^fc[0-9a-f]{2}:/.test(host) || + /^fd[0-9a-f]{2}:/.test(host) + ) return 'private'; + return 'public'; +} + /* Session 6a (Tier-2 polish): client-side "Join via invite" modal. * * User pastes a superscalar://join?... URL (or scans a QR with an @@ -26,6 +48,9 @@ function AcceptInviteModal({ show, onHide }: Props) { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + /* Polish 2026-05-29: explicit trust gate when the invite points at a + * publicly-routable IP. Loopback / tor / private don't require this. */ + const [trustAcked, setTrustAcked] = useState(false); useEffect(() => { if (!urlInput.trim()) { @@ -33,15 +58,19 @@ function AcceptInviteModal({ show, onHide }: Props) { setError(null); return; } - const inv = parseInviteUrl(urlInput); - if (!inv) { + const r = parseInviteUrlDetailed(urlInput); + if (!r.invite) { setParsed(null); - setError('Not a valid superscalar:// invite. Expected format: superscalar://join?iid=…&lsp=…'); + if (r.error === 'expired') { + setError('This invite expired. Ask the LSP operator for a fresh one.'); + } else { + setError('Not a valid superscalar:// invite. Expected format: superscalar://join?iid=…&lsp=…'); + } } else { - setParsed(inv); + setParsed(r.invite); setError(null); - if (inv.contributionMinSats != null && !contribution) { - setContribution(String(inv.contributionMinSats)); + if (r.invite.contributionMinSats != null && !contribution) { + setContribution(String(r.invite.contributionMinSats)); } } // eslint-disable-next-line @@ -140,6 +169,37 @@ function AcceptInviteModal({ show, onHide }: Props) { )} + {parsed && (() => { + const cls = classifyAddress(parsed.address); + if (cls === 'public') { + return ( + +
+ Heads up: sending this join request will phone home to + a publicly-routable IP, {parsed.address}. Make sure you got this + invite from a person you actually trust. +
+ setTrustAcked(e.target.checked)} + data-testid='accept-invite-trust-ack' + /> +
+ ); + } + if (cls === 'tor') { + return ( + + Connecting via Tor onion ({parsed.address}). Privacy-preserving. + + ); + } + return null; + })()} + {error && {error}} {success && {success}} @@ -149,7 +209,12 @@ function AcceptInviteModal({ show, onHide }: Props) {