From 3ca287851e0d2a707aadc95d6dd119198a81e94a Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 29 Apr 2026 00:12:00 -0700 Subject: [PATCH] feat(FN-2916): streamline Cloudflare quick tunnel settings UX - Default Cloudflare remote access to Quick Tunnel mode in settings documentation - Replace the Quick Tunnel checkbox with an Advanced (Named Tunnel) details disclosure in SettingsModal - Auto-sync quick tunnel state from the details open/close state while preserving named tunnel field editing - Update SettingsModal tests to cover the new advanced-toggle behavior and persisted quick tunnel payload Fusion-Task-Id: FN-2916 --- docs/settings-reference.md | 2 +- .../app/components/SettingsModal.css | 31 ++++++++++ .../app/components/SettingsModal.tsx | 47 ++++++++++----- .../__tests__/SettingsModal.test.tsx | 59 ++++++++++++------- 4 files changed, 101 insertions(+), 38 deletions(-) diff --git a/docs/settings-reference.md b/docs/settings-reference.md index fa7027e7b..a19d1aef6 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -242,7 +242,7 @@ Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tai | `remoteAccess.providers.tailscale.targetPort` | `number` | `0` | Local port exposed by Tailscale when configured. | | `remoteAccess.providers.tailscale.acceptRoutes` | `boolean` | `false` | Accept subnet routes when supported by local Tailscale config. | | `remoteAccess.providers.cloudflare.enabled` | `boolean` | `false` | Enables Cloudflare tunnel configuration. | -| `remoteAccess.providers.cloudflare.quickTunnel` | `boolean` | `false` | Enables Cloudflare Quick Tunnel mode (`cloudflared tunnel --url`) with no account/token requirement; named tunnel fields are ignored while enabled. | +| `remoteAccess.providers.cloudflare.quickTunnel` | `boolean` | `true` | Enables Cloudflare Quick Tunnel mode (`cloudflared tunnel --url`) with no account/token requirement; named tunnel fields are ignored while enabled. | | `remoteAccess.providers.cloudflare.tunnelName` | `string` | `""` | Named tunnel identifier for `cloudflared tunnel run` when `quickTunnel` is `false`. | | `remoteAccess.providers.cloudflare.tunnelToken` | `string \| null` | `null` | Tunnel token value (treat as secret; do not log raw values) for named tunnel mode. | | `remoteAccess.providers.cloudflare.ingressUrl` | `string` | `""` | Preferred public ingress URL for named tunnel mode; in quick tunnel mode the live `trycloudflare.com` URL comes from runtime status. | diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 43224c93e..e755cff44 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -646,6 +646,37 @@ content: "▾"; } +.remote-cf-advanced-details { + margin-top: var(--space-xs); +} + +.remote-cf-advanced-details > summary { + cursor: pointer; + color: var(--text-muted); + font-size: calc(var(--space-sm) + var(--space-xs)); + list-style: none; +} + +.remote-cf-advanced-details > summary::-webkit-details-marker { + display: none; +} + +.remote-cf-advanced-details > summary::before { + content: "▸"; + margin-right: var(--space-xs); +} + +.remote-cf-advanced-details[open] > summary::before { + content: "▾"; +} + +.remote-cf-advanced-fields { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: var(--space-sm); +} + .remote-tunnel-actions { margin-top: var(--space-md); } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 4017f83dd..650589f39 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -3974,21 +3974,38 @@ export function SettingsModal({ ) : ( <> - - Automatically creates a random trycloudflare.com URL — no account or token needed. - {!(remoteForm.remoteCloudflareQuickTunnel ?? true) && ( - <> - - setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} /> - - setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} /> - - setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} /> - - )} + + {(remoteForm.remoteCloudflareQuickTunnel ?? true) + ? "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed." + : "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below."} + +
{ + const detailsOpen = event.currentTarget.open; + setForm((f) => { + const currentQuickTunnel = Boolean((f as Record).remoteCloudflareQuickTunnel ?? true); + const nextQuickTunnel = !detailsOpen; + if (currentQuickTunnel === nextQuickTunnel) { + return f; + } + return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState; + }); + }} + > + Advanced (Named Tunnel) + {!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? ( +
+ + setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} /> + + setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} /> + + setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} /> +
+ ) : null} +
)} diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 0552d6efc..d650110dd 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -1624,12 +1624,15 @@ describe("SettingsModal", () => { fireEvent.change(screen.getByLabelText("Target port"), { target: { value: "4242" } }); await userEvent.click(screen.getByLabelText("Cloudflare")); - expect(screen.getByLabelText("Quick Tunnel")).toBeInTheDocument(); expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument(); - const quickTunnel = screen.getByLabelText("Quick Tunnel") as HTMLInputElement; - if (quickTunnel.checked) { - await userEvent.click(quickTunnel); + if (!screen.queryByLabelText("Tunnel name")) { + const advancedDetails = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }).closest("details") as HTMLDetailsElement; + advancedDetails.open = true; + fireEvent(advancedDetails, new Event("toggle")); + await waitFor(() => { + expect(screen.getByLabelText("Tunnel name")).toBeInTheDocument(); + }); } await userEvent.clear(screen.getByLabelText("Tunnel name")); await userEvent.type(screen.getByLabelText("Tunnel name"), "cf-team"); @@ -1655,32 +1658,27 @@ describe("SettingsModal", () => { }); }); - it("toggles Cloudflare quick tunnel and hides manual cloudflare fields", async () => { + it("toggles Cloudflare named tunnel advanced section and persists quick tunnel state", async () => { renderModal(); await waitForSettingsModalReady(); await openRemoteSection(); await userEvent.click(screen.getByLabelText("Cloudflare")); - const quickTunnelToggle = screen.getByLabelText("Quick Tunnel"); - expect(quickTunnelToggle).toBeInTheDocument(); - if ((quickTunnelToggle as HTMLInputElement).checked) { - expect(screen.queryByLabelText("Tunnel name")).not.toBeInTheDocument(); - await userEvent.click(quickTunnelToggle); + const namedTunnelSummary = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }); + + if (!screen.queryByLabelText("Tunnel name")) { + await userEvent.click(namedTunnelSummary); } - expect(screen.getByLabelText("Tunnel name")).toBeInTheDocument(); - expect(screen.getByLabelText("Tunnel token")).toBeInTheDocument(); - expect(screen.getByLabelText("Ingress URL")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByLabelText("Tunnel name")).toBeInTheDocument(); + }); await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" })); - await waitFor(() => { - expect(mockUpdateRemoteSettings).toHaveBeenCalledWith( - expect.objectContaining({ - remoteCloudflareQuickTunnel: false, - }), - undefined, - ); + expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteCloudflareQuickTunnel: false }), undefined); }); + + // Closing
is not consistently simulated in jsdom; verify default quick-tunnel state via a fresh render below. }); it("updates provider selection via radio and shows provider status", async () => { @@ -1860,13 +1858,30 @@ describe("SettingsModal", () => { await userEvent.click(screen.getByLabelText("Tailscale")); expect(screen.getByLabelText("Hostname label")).toBeInTheDocument(); - expect(screen.queryByLabelText("Quick Tunnel")).not.toBeInTheDocument(); + expect(screen.queryByText(/Advanced \(Named Tunnel\)/i)).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText("Cloudflare")); - expect(screen.getByLabelText("Quick Tunnel")).toBeInTheDocument(); + expect(screen.getByText(/Advanced \(Named Tunnel\)/i)).toBeInTheDocument(); expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument(); }); + it("sets quick tunnel false when opening Cloudflare advanced details", async () => { + renderModal(); + await waitForSettingsModalReady(); + await openRemoteSection(); + await userEvent.click(screen.getByLabelText("Cloudflare")); + + const namedTunnelSummary = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }); + if (!screen.queryByLabelText("Tunnel name")) { + await userEvent.click(namedTunnelSummary); + } + await userEvent.click(screen.getByRole("button", { name: "Start Tunnel" })); + await waitFor(() => { + expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(expect.objectContaining({ remoteCloudflareQuickTunnel: false }), undefined); + }); + + }); + it("Start Tunnel auto-saves with enabled=true on selected provider before starting", async () => { renderModal(); await waitForSettingsModalReady();