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
This commit is contained in:
Fusion
2026-04-29 00:12:00 -07:00
committed by gsxdsm
parent d44b688260
commit 3ca287851e
4 changed files with 101 additions and 38 deletions

View File

@@ -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. |

View File

@@ -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);
}

View File

@@ -3974,21 +3974,38 @@ export function SettingsModal({
</>
) : (
<>
<label htmlFor="remoteCloudflareQuickTunnel" className="checkbox-label">
<input id="remoteCloudflareQuickTunnel" type="checkbox" checked={Boolean(remoteForm.remoteCloudflareQuickTunnel ?? true)} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareQuickTunnel: e.target.checked } as SettingsFormState))} />
Quick Tunnel
</label>
<small>Automatically creates a random trycloudflare.com URL no account or token needed.</small>
{!(remoteForm.remoteCloudflareQuickTunnel ?? true) && (
<>
<label htmlFor="remoteCloudflareTunnelName">Tunnel name</label>
<input id="remoteCloudflareTunnelName" type="text" placeholder="Tunnel name" value={String(remoteForm.remoteCloudflareTunnelName ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareTunnelToken">Tunnel token</label>
<input id="remoteCloudflareTunnelToken" type="password" placeholder="Tunnel token" value={String(remoteForm.remoteCloudflareTunnelToken ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareIngressUrl">Ingress URL</label>
<input id="remoteCloudflareIngressUrl" type="text" placeholder="https://your-domain.example" value={String(remoteForm.remoteCloudflareIngressUrl ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} />
</>
)}
<small>
{(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."}
</small>
<details
className="remote-cf-advanced-details"
open={!(remoteForm.remoteCloudflareQuickTunnel ?? true)}
onToggle={(event) => {
const detailsOpen = event.currentTarget.open;
setForm((f) => {
const currentQuickTunnel = Boolean((f as Record<string, unknown>).remoteCloudflareQuickTunnel ?? true);
const nextQuickTunnel = !detailsOpen;
if (currentQuickTunnel === nextQuickTunnel) {
return f;
}
return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState;
});
}}
>
<summary>Advanced (Named Tunnel)</summary>
{!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? (
<div className="remote-cf-advanced-fields">
<label htmlFor="remoteCloudflareTunnelName">Tunnel name</label>
<input id="remoteCloudflareTunnelName" type="text" placeholder="Tunnel name" value={String(remoteForm.remoteCloudflareTunnelName ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareTunnelToken">Tunnel token</label>
<input id="remoteCloudflareTunnelToken" type="password" placeholder="Tunnel token" value={String(remoteForm.remoteCloudflareTunnelToken ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))} />
<label htmlFor="remoteCloudflareIngressUrl">Ingress URL</label>
<input id="remoteCloudflareIngressUrl" type="text" placeholder="https://your-domain.example" value={String(remoteForm.remoteCloudflareIngressUrl ?? "")} onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))} />
</div>
) : null}
</details>
</>
)}
</div>

View File

@@ -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 <details> 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();