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:
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user