fix(FN-000): polish onboarding auth and planning modal
This commit is contained in:
5
.changeset/fix-dashboard-onboarding-and-planning.md
Normal file
5
.changeset/fix-dashboard-onboarding-and-planning.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix dashboard onboarding auth token controls, keep the AI planning modal footer visible on desktop, and add better terminal PTY spawn diagnostics.
|
||||
@@ -97,6 +97,12 @@ export function getAuthToken(): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Persist a token for future dashboard API requests in this browser session. */
|
||||
export function setAuthToken(token: string): void {
|
||||
cachedToken = token;
|
||||
writeStoredToken(token);
|
||||
}
|
||||
|
||||
/** Clear the stored token (e.g., on a 401 response). */
|
||||
export function clearAuthToken(): void {
|
||||
cachedToken = undefined;
|
||||
|
||||
@@ -430,6 +430,38 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("modal height constraint regression", () => {
|
||||
it("desktop planning modal max-height accounts for overlay padding", async () => {
|
||||
const { container } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const modal = container.querySelector(".planning-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
||||
const css = fs.readFileSync(cssPath, "utf-8");
|
||||
|
||||
const blockMatch = css.match(
|
||||
/\.planning-modal\s*\{[^}]*max-height:\s*([^;]+);/,
|
||||
);
|
||||
expect(blockMatch).toBeTruthy();
|
||||
|
||||
const maxHeightValue = blockMatch![1].trim();
|
||||
expect(maxHeightValue).toContain("min(");
|
||||
expect(maxHeightValue).toContain("calc(");
|
||||
expect(maxHeightValue).toContain("100dvh");
|
||||
expect(maxHeightValue).toContain("--overlay-padding-top");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Planning flow", () => {
|
||||
it("starts planning and shows question view", async () => {
|
||||
render(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from "react";
|
||||
import { X, Loader2, Sparkles, CheckCircle, ChevronRight } from "lucide-react";
|
||||
import type { ProjectInfo, ProjectCreateInput } from "../api";
|
||||
import { registerProject } from "../api";
|
||||
import { getAuthToken, setAuthToken, clearAuthToken } from "../auth";
|
||||
import { DirectoryPicker } from "./DirectoryPicker";
|
||||
import { suggestProjectName } from "../utils/projectDetection";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
@@ -47,6 +48,8 @@ export function SetupWizardModal({
|
||||
error: null,
|
||||
});
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [authTokenInput, setAuthTokenInput] = useState("");
|
||||
const [storedAuthToken, setStoredAuthToken] = useState(() => getAuthToken());
|
||||
|
||||
const { nodes, loading: nodesLoading } = useNodes();
|
||||
const localNodeId = nodes.find((n) => n.type === "local")?.id;
|
||||
@@ -97,6 +100,20 @@ export function SetupWizardModal({
|
||||
}
|
||||
}, [state.manualPath, state.manualName, state.manualIsolationMode, state.manualNodeId, onProjectRegistered]);
|
||||
|
||||
const handleSetAuthToken = useCallback(() => {
|
||||
const token = authTokenInput.trim();
|
||||
if (!token) return;
|
||||
setAuthToken(token);
|
||||
window.location.reload();
|
||||
}, [authTokenInput]);
|
||||
|
||||
const handleResetAuthToken = useCallback(() => {
|
||||
clearAuthToken();
|
||||
setStoredAuthToken(undefined);
|
||||
setAuthTokenInput("");
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -254,6 +271,45 @@ export function SetupWizardModal({
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="setup-auth-token">Browser Auth Token</label>
|
||||
<div className="setup-wizard-auth-token">
|
||||
<input
|
||||
id="setup-auth-token"
|
||||
type="password"
|
||||
value={authTokenInput}
|
||||
onChange={(e) => setAuthTokenInput(e.target.value)}
|
||||
placeholder={storedAuthToken ? "Enter a new token to replace the stored one" : "Paste the auth token for this browser"}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="setup-wizard-auth-token-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleSetAuthToken}
|
||||
disabled={authTokenInput.trim().length === 0}
|
||||
>
|
||||
{storedAuthToken ? "Update token" : "Set token"}
|
||||
</button>
|
||||
{storedAuthToken && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleResetAuthToken}
|
||||
>
|
||||
Reset token
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="form-hint">
|
||||
{storedAuthToken
|
||||
? "A token is already stored in this browser. Updating or resetting it will reload the page."
|
||||
: "Store a token in this browser for authenticated dashboard requests, then reload the page."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,15 +45,30 @@ vi.mock("../../api", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../auth", () => ({
|
||||
getAuthToken: vi.fn(() => undefined),
|
||||
setAuthToken: vi.fn(),
|
||||
clearAuthToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { registerProject } from "../../api";
|
||||
import { getAuthToken, setAuthToken, clearAuthToken } from "../../auth";
|
||||
import { useNodes } from "../../hooks/useNodes";
|
||||
|
||||
const mockRegisterProject = vi.mocked(registerProject);
|
||||
const mockGetAuthToken = vi.mocked(getAuthToken);
|
||||
const mockSetAuthToken = vi.mocked(setAuthToken);
|
||||
const mockClearAuthToken = vi.mocked(clearAuthToken);
|
||||
const mockUseNodes = vi.mocked(useNodes);
|
||||
|
||||
describe("SetupWizardModal", () => {
|
||||
let reloadMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetAuthToken.mockReturnValue(undefined);
|
||||
reloadMock = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload: reloadMock });
|
||||
});
|
||||
|
||||
it("renders with welcome message", () => {
|
||||
@@ -248,6 +263,72 @@ describe("SetupWizardModal", () => {
|
||||
expect(childProcessRadio.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a set token action when no browser auth token is stored", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced settings"));
|
||||
|
||||
expect(screen.getByLabelText("Browser Auth Token")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Set token" })).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: "Reset token" })).toBeNull();
|
||||
});
|
||||
|
||||
it("stores a browser auth token and reloads the page", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced settings"));
|
||||
fireEvent.change(screen.getByLabelText("Browser Auth Token"), {
|
||||
target: { value: "daemon-token" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set token" }));
|
||||
|
||||
expect(mockSetAuthToken).toHaveBeenCalledWith("daemon-token");
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows reset when a browser auth token is already stored", () => {
|
||||
mockGetAuthToken.mockReturnValue("stored-token");
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced settings"));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Update token" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Reset token" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("resets the stored browser auth token and reloads the page", () => {
|
||||
mockGetAuthToken.mockReturnValue("stored-token");
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced settings"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset token" }));
|
||||
|
||||
expect(mockClearAuthToken).toHaveBeenCalledTimes(1);
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("node selector", () => {
|
||||
const localNode = {
|
||||
id: "local-1",
|
||||
|
||||
@@ -13191,7 +13191,7 @@ html .column.drag-over * {
|
||||
width: 90vw;
|
||||
max-width: 640px;
|
||||
min-height: 400px;
|
||||
max-height: 90vh;
|
||||
max-height: min(90vh, calc(100dvh - 2 * var(--overlay-padding-top, 10vh)));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -21597,6 +21597,22 @@ html .column.drag-over * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.setup-wizard-auth-token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.setup-wizard-auth-token-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.setup-wizard-auth-token-actions .btn {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -21749,6 +21765,11 @@ html .column.drag-over * {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.setup-wizard-auth-token-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.setup-wizard-footer {
|
||||
padding:
|
||||
var(--space-sm)
|
||||
|
||||
@@ -286,6 +286,27 @@ export class TerminalService extends EventEmitter {
|
||||
return { shell: "/bin/sh", args: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build concise diagnostics for PTY launch failures without logging the full environment.
|
||||
*/
|
||||
private getSpawnDiagnostics(
|
||||
requestedShell: string | undefined,
|
||||
detectedShell: string,
|
||||
detectedArgs: string[],
|
||||
cwd: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
platform: os.platform(),
|
||||
projectRoot: this.projectRoot,
|
||||
cwd,
|
||||
requestedShell: requestedShell ?? null,
|
||||
detectedShell,
|
||||
detectedArgs,
|
||||
envShell: process.env.SHELL ?? null,
|
||||
allowedShells: this.getAllowedShells().filter((shellPath) => existsSync(shellPath)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve a working directory path
|
||||
*/
|
||||
@@ -445,6 +466,7 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Validate and resolve working directory
|
||||
const cwd = await this.resolveWorkingDirectory(options.cwd);
|
||||
const spawnDiagnostics = this.getSpawnDiagnostics(options.shell, detectedShell, shellArgs, cwd);
|
||||
|
||||
// Build environment with stripped sensitive vars
|
||||
const cleanEnv: Record<string, string> = {};
|
||||
@@ -464,7 +486,11 @@ export class TerminalService extends EventEmitter {
|
||||
...options.env,
|
||||
};
|
||||
|
||||
console.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
console.info(`[createSession] Creating session ${id}`, {
|
||||
...spawnDiagnostics,
|
||||
selectedShell: shell,
|
||||
selectedArgs: shell === detectedShell ? shellArgs : [],
|
||||
});
|
||||
|
||||
// Lazy-load node-pty module with proper error handling
|
||||
let pty: typeof import("node-pty");
|
||||
@@ -529,12 +555,13 @@ export class TerminalService extends EventEmitter {
|
||||
console.error(
|
||||
`[createSession] PTY spawn failed (${attempt.reason}) for ${attempt.shell} ${attempt.args.join(" ")}:`,
|
||||
spawnError,
|
||||
spawnDiagnostics,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ptyProcess) {
|
||||
console.error(`[createSession] All PTY spawn attempts failed`, lastSpawnError);
|
||||
console.error(`[createSession] All PTY spawn attempts failed`, lastSpawnError, spawnDiagnostics);
|
||||
return {
|
||||
success: false,
|
||||
code: "pty_spawn_failed",
|
||||
|
||||
Reference in New Issue
Block a user