fix(FN-000): polish onboarding auth and planning modal
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user