fix(FN-2867): simplify remote access settings activation flow

- Remove the separate "Activate Provider" action from SettingsModal and rely on save-only provider configuration
- Ensure saving remote settings enables the selected active provider and keeps provider flags consistent
- Seed remoteAccess defaults in remote settings routes when project settings are missing instead of returning conflicts
- Expand dashboard tests to cover first-use/default remote settings behavior and updated provider lifecycle expectations
This commit is contained in:
Fusion
2026-04-28 10:52:14 -07:00
committed by gsxdsm
parent f37515120f
commit 9a1c21f373
5 changed files with 173 additions and 39 deletions

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEve
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -1451,13 +1451,18 @@ export function SettingsModal({
};
const handleSaveRemoteSettings = useCallback(async () => {
const activeProvider = ((form as Record<string, unknown>).remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
const nextSettings: Partial<RemoteSettings> = {
remoteActiveProvider: ((form as Record<string, unknown>).remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
remoteTailscaleEnabled: Boolean((form as Record<string, unknown>).remoteTailscaleEnabled),
remoteActiveProvider: activeProvider,
remoteTailscaleEnabled: activeProvider === "tailscale"
? true
: Boolean((form as Record<string, unknown>).remoteTailscaleEnabled),
remoteTailscaleHostname: String((form as Record<string, unknown>).remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number((form as Record<string, unknown>).remoteTailscaleTargetPort ?? 4040),
remoteTailscaleAcceptRoutes: Boolean((form as Record<string, unknown>).remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: Boolean((form as Record<string, unknown>).remoteCloudflareEnabled),
remoteCloudflareEnabled: activeProvider === "cloudflare"
? true
: Boolean((form as Record<string, unknown>).remoteCloudflareEnabled),
remoteCloudflareQuickTunnel: Boolean((form as Record<string, unknown>).remoteCloudflareQuickTunnel),
remoteCloudflareTunnelName: String((form as Record<string, unknown>).remoteCloudflareTunnelName ?? ""),
remoteCloudflareTunnelToken: (((form as Record<string, unknown>).remoteCloudflareTunnelToken as string | null) || null),
@@ -2410,6 +2415,55 @@ export function SettingsModal({
/>
<small>Maximum concurrent planning agents</small>
</div>
<div className="form-group">
<label htmlFor="defaultNodeId">Default Execution Node</label>
<select
id="defaultNodeId"
className="select"
value={typeof form.defaultNodeId === "string" ? form.defaultNodeId : ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultNodeId: val || undefined } as SettingsFormState));
}}
>
<option value="">Local execution (no default node)</option>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{node.name} ({getNodeStatusLabel(node.status)})
</option>
))}
</select>
{(() => {
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
if (!selectedNode) return null;
return (
<div className={`settings-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="settings-node-status__dot" aria-hidden="true" />
<span>{`Selected node: ${getNodeStatusLabel(selectedNode.status)}`}</span>
</div>
);
})()}
<small>Used when a task has no node override. Node status is shown for safer routing selection.</small>
</div>
<div className="form-group">
<label htmlFor="unavailableNodePolicy">Unavailable Node Policy</label>
<select
id="unavailableNodePolicy"
className="select"
value={
form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"
}
onChange={(e) =>
setForm((f) => ({
...f,
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
} as SettingsFormState))
}
>
<option value="block">Block execution</option>
<option value="fallback-local">Fallback to local</option>
</select>
</div>
<div className="form-group">
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
<input
@@ -3840,20 +3894,6 @@ export function SettingsModal({
<option value="cloudflare">Cloudflare Tunnel</option>
</select>
<div className="settings-button-row">
<button
type="button"
className="btn btn-sm"
disabled={!activeProvider || remoteBusyAction !== null}
onClick={() => {
if (!activeProvider) return;
void runRemoteAction("activate provider", async () => {
await activateRemoteProvider(activeProvider, projectId);
addToast(`Activated ${activeProvider}`, "success");
});
}}
>
Activate Provider
</button>
<button
type="button"
className="btn btn-sm"

View File

@@ -33,7 +33,6 @@ const mockCheckForUpdates = vi.fn();
const mockFetchRemoteSettings = vi.fn();
const mockUpdateRemoteSettings = vi.fn();
const mockFetchRemoteStatus = vi.fn();
const mockActivateRemoteProvider = vi.fn();
const mockStartRemoteTunnel = vi.fn();
const mockStopRemoteTunnel = vi.fn();
const mockRegenerateRemotePersistentToken = vi.fn();
@@ -72,7 +71,6 @@ vi.mock("../../api", () => ({
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
activateRemoteProvider: (...args: unknown[]) => mockActivateRemoteProvider(...args),
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
@@ -259,7 +257,6 @@ describe("SettingsModal", () => {
},
});
mockFetchRemoteStatus.mockResolvedValue({ provider: null, state: "stopped", url: null, lastError: null });
mockActivateRemoteProvider.mockResolvedValue({ activeProvider: "tailscale" });
mockStartRemoteTunnel.mockResolvedValue({ state: "starting", provider: "tailscale" });
mockStopRemoteTunnel.mockResolvedValue({ state: "stopped", provider: null });
mockRegenerateRemotePersistentToken.mockResolvedValue({ token: "token", maskedToken: "****" });
@@ -1517,6 +1514,30 @@ describe("SettingsModal", () => {
);
});
it("forces enabled=true for selected active provider when saving", async () => {
renderModal();
await waitForSettingsModalReady();
await openRemoteSection();
const tailscaleToggle = screen.getByLabelText("Enable Tailscale provider config");
expect(tailscaleToggle).not.toBeChecked();
await userEvent.selectOptions(screen.getByLabelText("Active provider"), "tailscale");
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
await waitFor(() => {
expect(mockUpdateRemoteSettings).toHaveBeenCalledTimes(1);
});
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteActiveProvider: "tailscale",
remoteTailscaleEnabled: true,
}),
undefined,
);
});
it("toggles Cloudflare quick tunnel and hides manual cloudflare fields", async () => {
renderModal();
await waitForSettingsModalReady();
@@ -1544,7 +1565,7 @@ describe("SettingsModal", () => {
});
});
it("updates active provider selection and provider status affordance after activation", async () => {
it("uses a save-only remote provider setup flow", async () => {
mockFetchRemoteStatus
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
.mockResolvedValueOnce({ provider: "tailscale", state: "running", url: "https://tail.example", lastError: null });
@@ -1553,13 +1574,21 @@ describe("SettingsModal", () => {
await waitForSettingsModalReady();
await openRemoteSection();
expect(screen.queryByRole("button", { name: "Activate Provider" })).not.toBeInTheDocument();
const activeProviderSelect = screen.getByLabelText("Active provider");
await userEvent.selectOptions(activeProviderSelect, "tailscale");
expect(activeProviderSelect).toHaveValue("tailscale");
await userEvent.click(screen.getByRole("button", { name: "Activate Provider" }));
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
await waitFor(() => {
expect(mockActivateRemoteProvider).toHaveBeenCalledWith("tailscale", undefined);
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
expect.objectContaining({
remoteActiveProvider: "tailscale",
remoteTailscaleEnabled: true,
}),
undefined,
);
});
await waitFor(() => {
expect(getTunnelStateSummary()).toHaveTextContent("Provider: tailscale");

View File

@@ -104,6 +104,28 @@ describe("remote access provider/lifecycle contracts", () => {
});
});
it("seeds defaults when activating a provider on a fresh project", async () => {
const updateSettings = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({}),
updateSettings,
});
const { app } = createApp({ store });
const activate = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "cloudflare" });
expect(activate.status).toBe(200);
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
remoteAccess: expect.objectContaining({
activeProvider: "cloudflare",
providers: expect.objectContaining({
tailscale: expect.objectContaining({ enabled: false }),
cloudflare: expect.objectContaining({ enabled: false }),
}),
}),
}));
});
it("returns NO_ACTIVE_PROVIDER when tunnel start is requested without an active provider", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
@@ -142,7 +164,7 @@ describe("remote access provider/lifecycle contracts", () => {
}
});
it("maps runtime prerequisite failures to a structured conflict response", async () => {
it("returns REMOTE_TUNNEL_PREREQUISITE_MISSING when provider is selected but runtime prerequisites are missing", async () => {
const store = createMockStore();
const engine = {
getTaskStore: vi.fn().mockReturnValue(store),

View File

@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { DEFAULT_PROJECT_SETTINGS, type TaskStore } from "@fusion/core";
import { createApiRoutes } from "../routes.js";
import { request as performRequest } from "../test-request.js";
@@ -131,6 +131,58 @@ describe("remote access API route contracts", () => {
}));
});
it("returns default remote settings payload when remoteAccess is missing", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({}),
});
const { app } = createApp({ store });
const getRes = await REQUEST(app, "GET", "/api/remote/settings");
expect(getRes.status).toBe(200);
expect(getRes.body).toMatchObject({
settings: expect.objectContaining({
remoteEnabled: false,
remoteActiveProvider: null,
remoteTailscaleEnabled: false,
remoteCloudflareEnabled: false,
remoteShortLivedEnabled: false,
}),
});
});
it("seeds defaults when saving remote settings on a fresh project", async () => {
const updateSettings = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({}),
updateSettings,
});
const { app } = createApp({ store });
const putRes = await REQUEST(app, "PUT", "/api/remote/settings", {
remoteActiveProvider: "tailscale",
remoteTailscaleEnabled: true,
remoteTailscaleHostname: "first-use.ts.net",
});
expect(putRes.status).toBe(200);
expect(updateSettings).toHaveBeenCalledWith({
remoteAccess: expect.objectContaining({
...DEFAULT_PROJECT_SETTINGS.remoteAccess,
activeProvider: "tailscale",
providers: expect.objectContaining({
tailscale: expect.objectContaining({ enabled: true, hostname: "first-use.ts.net" }),
cloudflare: expect.objectContaining({ enabled: false }),
}),
}),
});
expect(putRes.body.settings).toMatchObject({
remoteEnabled: true,
remoteActiveProvider: "tailscale",
remoteTailscaleEnabled: true,
});
});
it("supports provider activation and tunnel lifecycle endpoints", async () => {
const engine = {
startRemoteTunnel: vi.fn().mockResolvedValue({

View File

@@ -1,4 +1,5 @@
import {
DEFAULT_PROJECT_SETTINGS,
GLOBAL_SETTINGS_KEYS,
QMD_INSTALL_COMMAND,
MemoryBackendError,
@@ -316,11 +317,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const remoteAccess = settings.remoteAccess;
if (!remoteAccess) {
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
}
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
res.json({ settings: toRemoteSettingsPayload(remoteAccess) });
} catch (err: unknown) {
@@ -333,10 +330,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const remoteAccess = settings.remoteAccess;
if (!remoteAccess) {
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
}
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
const body = (req.body ?? {}) as Record<string, unknown>;
const nextRemoteAccess = {
@@ -433,10 +427,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const remoteAccess = settings.remoteAccess;
if (!remoteAccess) {
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
}
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
await scopedStore.updateSettings({
remoteAccess: {