diff --git a/.changeset/fn-7061-remote-access-graduation.md b/.changeset/fn-7061-remote-access-graduation.md new file mode 100644 index 0000000000..b07fef76c3 --- /dev/null +++ b/.changeset/fn-7061-remote-access-graduation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Make Remote Access settings visible without enabling an experimental flag. +category: feature +dev: Graduates the Settings UI section while leaving remoteAccess provider/token gating unchanged. diff --git a/docs/remote-access.md b/docs/remote-access.md index 5a36525599..b5e6d0e3ce 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -22,7 +22,7 @@ It documents only behavior implemented in the current codebase: ## 1.1 General requirements - Remote Access is **global-scoped** (`GlobalSettings.remoteAccess` in `packages/core/src/types.ts`). -- Configure it in dashboard settings (Remote tab) or via `PUT /api/settings/global` or `PUT /api/remote/settings`. +- Configure it in dashboard settings (Remote Access tab, always visible) or via `PUT /api/settings/global` or `PUT /api/remote/settings`. - A provider must be selected (`remoteAccess.activeProvider`) before tunnel start. - Start/stop is always manual through `/api/remote/tunnel/start` and `/api/remote/tunnel/stop`. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 180b9a4c74..6664fa7209 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -779,6 +779,7 @@ See also: ### Remote Access settings (global-scoped) Remote access settings are global-only (stored in `~/.fusion/settings.json`), not project-scoped. +The Settings → Remote Access section is always visible; starting a tunnel still requires `remoteAccess.enabled`, a selected provider, and provider-specific prerequisites. The canonical persisted shape is a nested `remoteAccess` object. Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tailscale/Cloudflare), tokenized login-link security caveats, and operational troubleshooting. Keep this section as a schema reference. diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 9a32706b97..c8e0b142b1 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -272,7 +272,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ const KNOWN_EXPERIMENTAL_FEATURES: Record = { insights: "Insights", memoryView: "Memory Editor", - remoteAccess: "Remote Access", skillsView: "Skills View", nodesView: "Nodes View", devServerView: "Dev Server", @@ -304,12 +303,16 @@ Right Dock Panel is no longer experimental: keep honoring the dock as always-on FNXC:SettingsExperimental 2026-06-23-01:31: Chat Rooms, Goals, Memory, Insights, Skills, and Todo graduated from Experimental. Hide stale persisted flags so users cannot accidentally disable now-default dashboard surfaces during upgrades. + +FNXC:SettingsExperimental 2026-06-26-00:00: +Remote Access graduated from Experimental — section is always available; stale persisted `remoteAccess` flags are hidden so upgrades cannot disable it. */ const HIDDEN_EXPERIMENTAL_FEATURE_KEYS = new Set([ "chatRooms", "goalsView", "insights", "memoryView", + "remoteAccess", "roadmap", "rightDock", "skillsView", @@ -816,14 +819,9 @@ export function SettingsModal({ const { nodes } = useNodes(); const experimentalFeatures = form.experimentalFeatures ?? {}; - const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess"); const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView"); const evalsViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "evalsView"); const visibleSections = SETTINGS_SECTIONS.filter((section) => { - if (section.id === "remote") { - return remoteAccessEnabled; - } - if (section.id === "research-global" || section.id === "research-project") { return researchViewEnabled; } @@ -842,11 +840,6 @@ export function SettingsModal({ const activeSectionScope = visibleSections.find((s) => s.id === activeSection)?.scope; useEffect(() => { - if (activeSection === "remote" && !remoteAccessEnabled) { - setActiveSection(firstVisibleSectionId); - return; - } - if ((activeSection === "research-global" || activeSection === "research-project") && !researchViewEnabled) { setActiveSection(firstVisibleSectionId); return; @@ -860,7 +853,7 @@ export function SettingsModal({ if (!visibleSections.some((section) => section.id === activeSection)) { setActiveSection(firstVisibleSectionId); } - }, [activeSection, remoteAccessEnabled, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, visibleSections]); + }, [activeSection, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, visibleSections]); // Auth state (independent of the settings save flow) const [authProviders, setAuthProviders] = useState([]); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx index c46559a8ac..d14a4f4f62 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.remote-notifications.test.tsx @@ -220,6 +220,19 @@ describe("SettingsModal", () => { await user.click(summary); }; + it("opens the Remote Access section when the legacy experimental flag is absent", async () => { + mockFetchSettings.mockResolvedValue({ + ...defaultSettings, + experimentalFeatures: {}, + }); + + renderModal(); + await waitForSettingsModalReady(); + await openRemoteSection(); + + expect(screen.getByRole("heading", { name: "Remote Access" })).toBeInTheDocument(); + }); + describe("with default Remote render", () => { beforeEach(async () => { renderModal(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx index 7eacd9794f..111127cb3f 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx @@ -1388,53 +1388,59 @@ describe("SettingsModal", () => { }); describe("section visibility behind experimental flags", () => { - it("hides Remote Access nav item when experimentalFeatures.remoteAccess is falsy", async () => { - mockFetchSettings.mockResolvedValue({ - ...defaultSettings, - experimentalFeatures: {}, - }); - - renderModal(); - await waitForSettingsModalReady(); - - expect(screen.queryByRole("button", { name: /Remote Access/i })).not.toBeInTheDocument(); - }); - - it("shows Remote Access nav item when experimentalFeatures.remoteAccess is true", async () => { - mockFetchSettings.mockResolvedValue({ - ...defaultSettings, - experimentalFeatures: { remoteAccess: true }, - }); + it.each([ + ["experimentalFeatures undefined", undefined], + ["experimentalFeatures empty", {}], + ["legacy remoteAccess false", { remoteAccess: false }], + ["legacy remoteAccess true", { remoteAccess: true }], + ] as const)("shows Remote Access nav item when %s", async (_label, experimentalFeatures) => { + const { experimentalFeatures: _omitted, ...settingsWithoutExperimentalFeatures } = defaultSettings; + mockFetchSettings.mockResolvedValue( + experimentalFeatures === undefined + ? settingsWithoutExperimentalFeatures + : { + ...defaultSettings, + experimentalFeatures, + }, + ); renderModal(); expect(await screen.findByRole("button", { name: /Remote Access/i })).toBeInTheDocument(); }); - it("shows Remote Access in KNOWN_EXPERIMENTAL_FEATURES toggle list", async () => { - mockFetchSettings.mockResolvedValue({ - ...defaultSettings, - experimentalFeatures: {}, - }); + it.each([ + ["experimentalFeatures undefined", undefined], + ["experimentalFeatures empty", {}], + ["legacy remoteAccess false", { remoteAccess: false }], + ["legacy remoteAccess true", { remoteAccess: true }], + ] as const)("does not render a Remote Access experimental toggle when %s", async (_label, experimentalFeatures) => { + const { experimentalFeatures: _omitted, ...settingsWithoutExperimentalFeatures } = defaultSettings; + mockFetchSettings.mockResolvedValue( + experimentalFeatures === undefined + ? settingsWithoutExperimentalFeatures + : { + ...defaultSettings, + experimentalFeatures, + }, + ); renderModal(); await openExperimentalFeaturesSection(); - expect(screen.getByLabelText("Remote Access")).toBeInTheDocument(); + expect(screen.queryByLabelText("Remote Access")).not.toBeInTheDocument(); }); - it("falls back to the first selectable section when opening remote while remoteAccess is disabled", async () => { + it("stays on the remote section when opening remote while remoteAccess is absent", async () => { mockFetchSettings.mockResolvedValue({ ...defaultSettings, experimentalFeatures: {}, }); renderModal({ initialSection: "remote" }); - await waitForSettingsModalReady(); - expect(screen.queryByRole("button", { name: /Remote Access/i })).not.toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Remote Access" })).toBeInTheDocument(); }); it("hides research settings nav items when experimentalFeatures.researchView is disabled", async () => {