From e3931ffe61bee1a2363772627d9ed98fa7e7c31f Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:49:34 -0700 Subject: [PATCH 1/3] access setting issues in chatbox --- .../builder/ChatboxBuilderExperience.tsx | 33 ++- .../chatboxes/builder/ChatboxBuilderView.tsx | 263 ++++++++++++------ .../__tests__/ChatboxBuilderView.test.tsx | 236 +++++++++++++++- .../__tests__/setup-checklist-panel.test.tsx | 27 +- .../builder/setup-checklist-panel.tsx | 47 ++-- 5 files changed, 485 insertions(+), 121 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderExperience.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderExperience.tsx index 67a753794..cc302386d 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderExperience.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderExperience.tsx @@ -19,7 +19,10 @@ import { } from "@/hooks/useWorkspaces"; import { readBuilderSession, clearBuilderSession } from "@/lib/chatbox-session"; import { ChatboxIndexPage, type ChatboxOpenOptions } from "./ChatboxIndexPage"; -import { ChatboxBuilderView } from "./ChatboxBuilderView"; +import { + ChatboxBuilderView, + type SavedDraftNavigationOptions, +} from "./ChatboxBuilderView"; import { ChatboxLauncher } from "./ChatboxLauncher"; import { getDefaultHostedModelId } from "./drafts"; import type { ChatboxDraftConfig, ChatboxStarterDefinition } from "./types"; @@ -65,6 +68,8 @@ export default function ChatboxBuilderExperience({ const [restoredViewMode, setRestoredViewMode] = useState< "setup" | "preview" | "usage" | "insights" | undefined >(); + const [restoredFocusedSetupSection, setRestoredFocusedSetupSection] = + useState(); const [starterLauncherOpen, setStarterLauncherOpen] = useState(false); const [deletingChatboxId, setDeletingChatboxId] = useState( null, @@ -94,6 +99,7 @@ export default function ChatboxBuilderExperience({ startTransition(() => { setSelectedChatboxId(session.chatboxId); setDraft((session.draft as ChatboxDraftConfig | null) ?? null); + setRestoredFocusedSetupSection(undefined); const vm = session.viewMode; if (vm === "builder") { setRestoredViewMode("setup"); @@ -116,6 +122,7 @@ export default function ChatboxBuilderExperience({ setSelectedChatboxId(null); setDraft(starter.createDraft(getDefaultHostedModelId())); setRestoredViewMode(undefined); + setRestoredFocusedSetupSection(undefined); setStarterLauncherOpen(false); }); }, @@ -133,13 +140,19 @@ export default function ChatboxBuilderExperience({ [applyStarterDraft], ); - const handleSavedDraft = useCallback((chatbox: ChatboxSettings) => { - startTransition(() => { - setDraft(null); - setSelectedChatboxId(chatbox.chatboxId); - setRestoredViewMode(undefined); - }); - }, []); + const handleSavedDraft = useCallback( + (chatbox: ChatboxSettings, options?: SavedDraftNavigationOptions) => { + startTransition(() => { + setDraft(null); + setSelectedChatboxId(chatbox.chatboxId); + setRestoredViewMode(options?.initialViewMode); + setRestoredFocusedSetupSection( + options?.initialFocusedSetupSection, + ); + }); + }, + [], + ); const handleDeleteChatbox = useCallback( async (chatbox: ChatboxListItem) => { @@ -177,6 +190,7 @@ export default function ChatboxBuilderExperience({ startTransition(() => { setSelectedChatboxId(newId); setRestoredViewMode(undefined); + setRestoredFocusedSetupSection(undefined); }); } } catch (error) { @@ -220,6 +234,7 @@ export default function ChatboxBuilderExperience({ chatboxId={selectedChatboxId} draft={draft} initialViewMode={restoredViewMode} + initialFocusedSetupSection={restoredFocusedSetupSection} onSavedDraft={handleSavedDraft} onBack={() => { clearBuilderSession(); @@ -227,6 +242,7 @@ export default function ChatboxBuilderExperience({ setSelectedChatboxId(null); setDraft(null); setRestoredViewMode(undefined); + setRestoredFocusedSetupSection(undefined); }); }} /> @@ -238,6 +254,7 @@ export default function ChatboxBuilderExperience({ startTransition(() => { setSelectedChatboxId(chatboxId); setRestoredViewMode(options?.initialViewMode); + setRestoredFocusedSetupSection(undefined); }); }} onDuplicateChatbox={handleDuplicateChatbox} diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderView.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderView.tsx index 537627a20..15cdc873f 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderView.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderView.tsx @@ -90,8 +90,12 @@ interface ChatboxBuilderViewProps { chatboxId?: string | null; draft: ChatboxDraftConfig | null; initialViewMode?: "setup" | "preview" | "usage" | "insights"; + initialFocusedSetupSection?: SetupSectionId | null; onBack: () => void; - onSavedDraft: (chatbox: ChatboxSettings) => void; + onSavedDraft: ( + chatbox: ChatboxSettings, + options?: SavedDraftNavigationOptions, + ) => void; } /** Right (setup) rail: favor setup on desktop */ @@ -100,6 +104,12 @@ const DESKTOP_SETUP_RAIL_MIN_PERCENT = 40; const DESKTOP_SETUP_RAIL_MAX_PERCENT = 70; type ViewMode = "setup" | "preview" | "usage" | "insights"; +type SavedDraftViewMode = Extract; + +export interface SavedDraftNavigationOptions { + initialViewMode?: SavedDraftViewMode; + initialFocusedSetupSection?: SetupSectionId | null; +} function normalizeInitialViewMode( mode: string | undefined, @@ -287,6 +297,7 @@ export function ChatboxBuilderView({ chatboxId, draft, initialViewMode, + initialFocusedSetupSection, onBack, onSavedDraft, }: ChatboxBuilderViewProps) { @@ -341,12 +352,13 @@ export function ChatboxBuilderView({ const [isSetupSheetOpen, setIsSetupSheetOpen] = useState(true); const [selectedNodeId, setSelectedNodeId] = useState("host"); const [focusedSetupSection, setFocusedSetupSection] = - useState(null); + useState(initialFocusedSetupSection ?? null); const [desktopSettingsPaneSize, setDesktopSettingsPaneSize] = useState( DESKTOP_SETUP_RAIL_DEFAULT_PERCENT, ); const [isSaving, setIsSaving] = useState(false); const [isAddServerOpen, setIsAddServerOpen] = useState(false); + const [stagedAccessInviteEmail, setStagedAccessInviteEmail] = useState(""); const [canvasViewportRefitNonce, setCanvasViewportRefitNonce] = useState(0); const panelGroupContainerRef = useRef(null); const rightPanelRef = useRef(null); @@ -366,6 +378,25 @@ export function ChatboxBuilderView({ }); }, [workspaceId, chatboxId, draftChatboxConfig, viewMode]); + useEffect(() => { + const nextViewMode = normalizeInitialViewMode(initialViewMode); + if (!nextViewMode) return; + setViewMode(nextViewMode); + }, [initialViewMode]); + + useEffect(() => { + if (initialFocusedSetupSection === undefined) return; + setFocusedSetupSection(initialFocusedSetupSection ?? null); + if (initialFocusedSetupSection) { + setIsSetupSheetOpen(true); + } + }, [initialFocusedSetupSection]); + + useEffect(() => { + if (draftChatboxConfig.mode === "invited_only") return; + setStagedAccessInviteEmail((current) => (current ? "" : current)); + }, [draftChatboxConfig.mode]); + const behaviorFingerprint = useMemo( () => JSON.stringify({ @@ -731,97 +762,157 @@ export function ChatboxBuilderView({ [markOAuthRequired], ); - const saveChatbox = useCallback(async (): Promise => { - const trimmedName = draftChatboxConfig.name.trim(); - if (!trimmedName) { - toast.error("Chatbox name is required"); - return false; - } - if (draftChatboxConfig.selectedServerIds.length === 0) { - toast.error("Select at least one HTTPS server"); - return false; - } - if ( - countRequiredServers( - draftChatboxConfig.selectedServerIds, - draftChatboxConfig.optionalServerIds, - ) < 1 - ) { - toast.error("At least one server must be required (on by default)"); - return false; - } - const selectedServers = workspaceServers.filter((server) => - draftChatboxConfig.selectedServerIds.includes(server._id), - ); - if (selectedServers.some((server) => isInsecureUrl(server.url))) { - toast.error("Only HTTPS servers can be used in chatboxes"); - return false; - } + const saveChatbox = useCallback( + async ({ + targetViewMode, + }: { + targetViewMode?: SavedDraftViewMode; + } = {}): Promise => { + const requestedViewMode = + targetViewMode ?? (viewMode === "preview" ? "preview" : "setup"); + const trimmedName = draftChatboxConfig.name.trim(); + if (!trimmedName) { + toast.error("Chatbox name is required"); + return false; + } + if (draftChatboxConfig.selectedServerIds.length === 0) { + toast.error("Select at least one HTTPS server"); + return false; + } + if ( + countRequiredServers( + draftChatboxConfig.selectedServerIds, + draftChatboxConfig.optionalServerIds, + ) < 1 + ) { + toast.error("At least one server must be required (on by default)"); + return false; + } + const selectedServers = workspaceServers.filter((server) => + draftChatboxConfig.selectedServerIds.includes(server._id), + ); + if (selectedServers.some((server) => isInsecureUrl(server.url))) { + toast.error("Only HTTPS servers can be used in chatboxes"); + return false; + } - setIsSaving(true); - try { - const payload = { - name: trimmedName, - description: draftChatboxConfig.description.trim() || undefined, - hostStyle: draftChatboxConfig.hostStyle, - systemPrompt: - draftChatboxConfig.systemPrompt.trim() || DEFAULT_SYSTEM_PROMPT, - modelId: draftChatboxConfig.modelId, - temperature: draftChatboxConfig.temperature, - requireToolApproval: draftChatboxConfig.requireToolApproval, - serverIds: draftChatboxConfig.selectedServerIds, - optionalServerIds: draftChatboxConfig.optionalServerIds, - allowGuestAccess: draftChatboxConfig.allowGuestAccess, - welcomeDialog: draftChatboxConfig.welcomeDialog, - feedbackDialog: draftChatboxConfig.feedbackDialog, - }; + setIsSaving(true); + try { + const payload = { + name: trimmedName, + description: draftChatboxConfig.description.trim() || undefined, + hostStyle: draftChatboxConfig.hostStyle, + systemPrompt: + draftChatboxConfig.systemPrompt.trim() || DEFAULT_SYSTEM_PROMPT, + modelId: draftChatboxConfig.modelId, + temperature: draftChatboxConfig.temperature, + requireToolApproval: draftChatboxConfig.requireToolApproval, + serverIds: draftChatboxConfig.selectedServerIds, + optionalServerIds: draftChatboxConfig.optionalServerIds, + allowGuestAccess: draftChatboxConfig.allowGuestAccess, + welcomeDialog: draftChatboxConfig.welcomeDialog, + feedbackDialog: draftChatboxConfig.feedbackDialog, + }; - if (!chatbox) { - let created = (await createChatbox({ - workspaceId, - ...payload, - })) as ChatboxSettings; - if (draftChatboxConfig.mode !== "invited_only") { - created = (await setChatboxMode({ - chatboxId: created.chatboxId, - mode: draftChatboxConfig.mode, + if (!chatbox) { + let created = (await createChatbox({ + workspaceId, + ...payload, })) as ChatboxSettings; + if (draftChatboxConfig.mode !== "invited_only") { + created = (await setChatboxMode({ + chatboxId: created.chatboxId, + mode: draftChatboxConfig.mode, + })) as ChatboxSettings; + } + + const normalizedStagedInviteEmail = + draftChatboxConfig.mode === "invited_only" + ? stagedAccessInviteEmail.trim().toLowerCase() + : ""; + let navigation: SavedDraftNavigationOptions = { + initialViewMode: requestedViewMode, + }; + + if (normalizedStagedInviteEmail) { + try { + await upsertChatboxMember({ + chatboxId: created.chatboxId, + email: normalizedStagedInviteEmail, + sendInviteEmail: true, + }); + setStagedAccessInviteEmail(""); + if (requestedViewMode === "setup") { + navigation = { + initialViewMode: "setup", + initialFocusedSetupSection: "access", + }; + } + toast.success("Chatbox created and invite sent"); + } catch (error) { + const inviteFailureNavigation: SavedDraftNavigationOptions = { + initialViewMode: "setup", + initialFocusedSetupSection: "access", + }; + toast.success("Chatbox created"); + toast.error( + error instanceof Error + ? error.message + : "Failed to send invite", + ); + setViewMode("setup"); + setFocusedSetupSection("access"); + setIsSetupSheetOpen(true); + onSavedDraft(created, inviteFailureNavigation); + return true; + } + } else { + toast.success("Chatbox created"); + } + + setViewMode(navigation.initialViewMode ?? "setup"); + setFocusedSetupSection(navigation.initialFocusedSetupSection ?? null); + if (navigation.initialViewMode === "setup") { + setIsSetupSheetOpen(true); + } + onSavedDraft(created, navigation); + return true; } - toast.success("Chatbox created"); - setViewMode("preview"); - onSavedDraft(created); + + await updateChatbox({ + chatboxId: chatbox.chatboxId, + ...payload, + }); + toast.success("Chatbox updated"); return true; + } catch (error) { + toast.error(getBillingErrorMessage(error, "Failed to save chatbox")); + return false; + } finally { + setIsSaving(false); } - - await updateChatbox({ - chatboxId: chatbox.chatboxId, - ...payload, - }); - toast.success("Chatbox updated"); - return true; - } catch (error) { - toast.error(getBillingErrorMessage(error, "Failed to save chatbox")); - return false; - } finally { - setIsSaving(false); - } - }, [ - createChatbox, - draftChatboxConfig, - onSavedDraft, - chatbox, - setChatboxMode, - updateChatbox, - workspaceId, - workspaceServers, - ]); + }, + [ + createChatbox, + draftChatboxConfig, + onSavedDraft, + chatbox, + setChatboxMode, + stagedAccessInviteEmail, + updateChatbox, + upsertChatboxMember, + viewMode, + workspaceId, + workspaceServers, + ], + ); const saveAndOpenPreview = useCallback(async () => { - const ok = await saveChatbox(); - if (ok) { + const ok = await saveChatbox({ targetViewMode: "preview" }); + if (ok && chatbox) { setViewMode("preview"); } - }, [saveChatbox]); + }, [chatbox, saveChatbox]); const handleCopyLink = useCallback(async () => { if (!shareLink) { @@ -972,10 +1063,12 @@ export function ChatboxBuilderView({ setIsAddServerOpen(true); }, onToggleServer: handleToggleServer, - inviteChatboxMember: chatboxId + stagedAccessInviteEmail, + onStagedAccessInviteEmailChange: setStagedAccessInviteEmail, + inviteChatboxMember: (chatbox?.chatboxId ?? chatboxId) ? async (email: string) => { await upsertChatboxMember({ - chatboxId, + chatboxId: (chatbox?.chatboxId ?? chatboxId)!, email: email.trim().toLowerCase(), sendInviteEmail: true, }); diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/ChatboxBuilderView.test.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/ChatboxBuilderView.test.tsx index 58e66d358..022820bf0 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/ChatboxBuilderView.test.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/ChatboxBuilderView.test.tsx @@ -1,16 +1,38 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import type { ChatboxSettings } from "@/hooks/useChatboxes"; import { ChatboxBuilderView } from "../ChatboxBuilderView"; import { CHATBOX_STARTERS, toDraftConfig } from "../drafts"; -const { mockUseChatbox, mockChatTabV2 } = vi.hoisted(() => ({ +const { + mockUseChatbox, + mockChatTabV2, + mockCreateChatbox, + mockUpdateChatbox, + mockSetChatboxMode, + mockUpsertChatboxMember, + mockToastError, + mockToastSuccess, +} = vi.hoisted(() => ({ mockUseChatbox: vi.fn(() => ({ chatbox: null })), mockChatTabV2: vi.fn(), + mockCreateChatbox: vi.fn(), + mockUpdateChatbox: vi.fn(), + mockSetChatboxMode: vi.fn(), + mockUpsertChatboxMember: vi.fn(), + mockToastError: vi.fn(), + mockToastSuccess: vi.fn(), })); vi.mock("sonner", () => ({ - toast: { error: vi.fn(), success: vi.fn() }, + toast: { error: mockToastError, success: mockToastSuccess }, })); vi.mock("convex/react", () => ({ @@ -20,9 +42,10 @@ vi.mock("convex/react", () => ({ vi.mock("@/hooks/useChatboxes", () => ({ useChatbox: (...args: unknown[]) => mockUseChatbox(...args), useChatboxMutations: () => ({ - createChatbox: vi.fn(), - updateChatbox: vi.fn(), - setChatboxMode: vi.fn(), + createChatbox: mockCreateChatbox, + updateChatbox: mockUpdateChatbox, + setChatboxMode: mockSetChatboxMode, + upsertChatboxMember: mockUpsertChatboxMember, }), })); @@ -34,6 +57,12 @@ vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false, })); +vi.mock("@/lib/chatbox-host-style", () => ({ + getChatboxHostLogo: () => "/mock-host-logo.png", + getChatboxHostStyleShortLabel: (hostStyle: string) => + hostStyle === "claude" ? "Claude" : "ChatGPT", +})); + vi.mock("@/hooks/hosted/use-hosted-oauth-gate", () => ({ useHostedOAuthGate: () => ({ oauthStateByServerId: {}, @@ -93,7 +122,10 @@ const httpsServer = { updatedAt: 1, }; -function createSavedChatbox(hostStyle: "claude" | "chatgpt"): ChatboxSettings { +function createSavedChatbox( + hostStyle: "claude" | "chatgpt", + overrides: Partial = {}, +): ChatboxSettings { return { chatboxId: `sbx-${hostStyle}`, workspaceId: "ws-1", @@ -120,6 +152,16 @@ function createSavedChatbox(hostStyle: "claude" | "chatgpt"): ChatboxSettings { everyNToolCalls: 1, promptHint: "", }, + ...overrides, + }; +} + +function createUnsavedInviteOnlyDraft() { + return { + ...CHATBOX_STARTERS.find((s) => s.id === "internal-qa")!.createDraft( + "openai/gpt-5-mini", + ), + selectedServerIds: [httpsServer._id], }; } @@ -128,6 +170,12 @@ describe("ChatboxBuilderView", () => { mockUseChatbox.mockReset(); mockUseChatbox.mockReturnValue({ chatbox: null }); mockChatTabV2.mockReset(); + mockCreateChatbox.mockReset(); + mockUpdateChatbox.mockReset(); + mockSetChatboxMode.mockReset(); + mockUpsertChatboxMember.mockReset(); + mockToastError.mockReset(); + mockToastSuccess.mockReset(); }); it("shows Save changes on the header save button when a saved chatbox is dirty (no Unsaved badge)", () => { @@ -218,6 +266,180 @@ describe("ChatboxBuilderView", () => { expect(screen.getByRole("button", { name: /^Save$/i })).not.toBeDisabled(); }); + it("creates the chatbox, sends the staged invite, and reopens Setup > Access on first save", async () => { + const user = userEvent.setup(); + const createdChatbox = createSavedChatbox("claude", { + name: "Internal QA", + mode: "invited_only", + }); + const onSavedDraft = vi.fn(); + mockCreateChatbox.mockResolvedValue(createdChatbox); + mockUpsertChatboxMember.mockResolvedValue(undefined); + + render( + {}} + onSavedDraft={onSavedDraft} + />, + ); + + await user.click(screen.getByRole("button", { name: /Access/i })); + fireEvent.change(screen.getByLabelText(/email address/i), { + target: { value: "tester@example.com" }, + }); + await user.click(screen.getByRole("button", { name: /^Save$/i })); + + await waitFor(() => expect(mockCreateChatbox).toHaveBeenCalledTimes(1)); + expect(mockCreateChatbox).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "ws-1", + serverIds: [httpsServer._id], + }), + ); + expect(mockSetChatboxMode).not.toHaveBeenCalled(); + await waitFor(() => + expect(mockUpsertChatboxMember).toHaveBeenCalledWith({ + chatboxId: createdChatbox.chatboxId, + email: "tester@example.com", + sendInviteEmail: true, + }), + ); + expect(mockCreateChatbox.mock.invocationCallOrder[0]).toBeLessThan( + mockUpsertChatboxMember.mock.invocationCallOrder[0], + ); + await waitFor(() => + expect(onSavedDraft).toHaveBeenCalledWith(createdChatbox, { + initialViewMode: "setup", + initialFocusedSetupSection: "access", + }), + ); + }); + + it("sends the staged invite before Save and open preview switches to preview", async () => { + const user = userEvent.setup(); + const createdChatbox = createSavedChatbox("claude", { + name: "Internal QA", + mode: "invited_only", + }); + const onSavedDraft = vi.fn(); + mockCreateChatbox.mockResolvedValue(createdChatbox); + mockUpsertChatboxMember.mockResolvedValue(undefined); + + render( + {}} + onSavedDraft={onSavedDraft} + />, + ); + + await user.click(screen.getByRole("button", { name: /Access/i })); + fireEvent.change(screen.getByLabelText(/email address/i), { + target: { value: "preview@example.com" }, + }); + await user.click( + screen.getByRole("button", { name: "Save and open preview" }), + ); + + await waitFor(() => expect(mockCreateChatbox).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(mockUpsertChatboxMember).toHaveBeenCalledWith({ + chatboxId: createdChatbox.chatboxId, + email: "preview@example.com", + sendInviteEmail: true, + }), + ); + expect(mockCreateChatbox.mock.invocationCallOrder[0]).toBeLessThan( + mockUpsertChatboxMember.mock.invocationCallOrder[0], + ); + await waitFor(() => + expect(onSavedDraft).toHaveBeenCalledWith(createdChatbox, { + initialViewMode: "preview", + }), + ); + }); + + it("keeps the staged invite email in place when save is blocked and does not attempt an invite", async () => { + const user = userEvent.setup(); + const draft = CHATBOX_STARTERS.find((s) => s.id === "internal-qa")!.createDraft( + "openai/gpt-5-mini", + ); + render( + {}} + onSavedDraft={() => {}} + />, + ); + + await user.click(screen.getByRole("button", { name: /Access/i })); + const emailInput = screen.getByLabelText(/email address/i); + fireEvent.change(emailInput, { + target: { value: "blocked@example.com" }, + }); + + const saveButton = screen.getByRole("button", { name: /^Save$/i }); + expect(saveButton).toBeDisabled(); + await user.click(saveButton); + + expect(emailInput).toHaveValue("blocked@example.com"); + expect(mockCreateChatbox).not.toHaveBeenCalled(); + expect(mockUpsertChatboxMember).not.toHaveBeenCalled(); + }); + + it("keeps the created chatbox and reopens Setup > Access when the staged invite fails", async () => { + const user = userEvent.setup(); + const createdChatbox = createSavedChatbox("claude", { + name: "Internal QA", + mode: "invited_only", + }); + const onSavedDraft = vi.fn(); + mockCreateChatbox.mockResolvedValue(createdChatbox); + mockUpsertChatboxMember.mockRejectedValue(new Error("Invite failed")); + + render( + {}} + onSavedDraft={onSavedDraft} + />, + ); + + await user.click(screen.getByRole("button", { name: /Access/i })); + fireEvent.change(screen.getByLabelText(/email address/i), { + target: { value: "retry@example.com" }, + }); + await user.click( + screen.getByRole("button", { name: "Save and open preview" }), + ); + + await waitFor(() => expect(mockCreateChatbox).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(mockUpsertChatboxMember).toHaveBeenCalledWith({ + chatboxId: createdChatbox.chatboxId, + email: "retry@example.com", + sendInviteEmail: true, + }), + ); + await waitFor(() => + expect(onSavedDraft).toHaveBeenCalledWith(createdChatbox, { + initialViewMode: "setup", + initialFocusedSetupSection: "access", + }), + ); + expect(mockToastSuccess).toHaveBeenCalledWith("Chatbox created"); + expect(mockToastError).toHaveBeenCalledWith("Invite failed"); + }); + it("disables Preview, Sessions, and Clusters until the chatbox is saved", () => { const draft = CHATBOX_STARTERS.find((s) => s.id === "blank")!.createDraft( "openai/gpt-5-mini", diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/setup-checklist-panel.test.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/setup-checklist-panel.test.tsx index 4ad7beff8..dda8b2c4f 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/setup-checklist-panel.test.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/setup-checklist-panel.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, within } from "@testing-library/react"; import { computeSectionStatuses, @@ -8,14 +8,25 @@ import { import { CHATBOX_STARTERS } from "../drafts"; import type { RemoteServer } from "@/hooks/useWorkspaces"; +vi.mock("@/lib/chatbox-host-style", () => ({ + getChatboxHostLogo: () => "/mock-host-logo.png", + getChatboxHostStyleShortLabel: (hostStyle: string) => + hostStyle === "claude" ? "Claude" : "ChatGPT", +})); + const baseDraft = CHATBOX_STARTERS.find((s) => s.id === "blank")!.createDraft( "openai/gpt-5-mini", ); +const stagedInviteProps = { + stagedAccessInviteEmail: "", + onStagedAccessInviteEmailChange: vi.fn(), +}; describe("SetupChecklistPanel", () => { it("does not render the Setup header row on desktop (no onCloseMobile)", () => { render( { it("shows a subdued checkmark label with Done for complete sections (not a colored pill)", () => { render( { it("uses the same muted inline template for Optional, Default on, and Collapsed (no secondary badges)", () => { render( { it("renders mobile Done header when onCloseMobile is provided", () => { render( { it("shows draft access controls inline in Access (no General access heading)", () => { render( { ).toBeInTheDocument(); }); - it("shows invite-only save prompt in Access when chatbox is unsaved", () => { + it("stages invite-only emails in Access when the chatbox is unsaved", () => { const internalDraft = CHATBOX_STARTERS.find( (s) => s.id === "internal-qa", )!.createDraft("openai/gpt-5-mini"); render( { fireEvent.click(screen.getByRole("button", { name: /Access/i })); expect( - screen.getByText(/Save the chatbox to invite people by email/i), + screen.getByText(/The invite will be sent when you save this chatbox/i), ).toBeInTheDocument(); const emailInput = screen.getByLabelText(/email address/i); - expect(emailInput).toBeDisabled(); - expect(screen.getByRole("button", { name: /^Invite$/i })).toBeDisabled(); + expect(emailInput).toBeEnabled(); + expect(screen.getByRole("button", { name: /Save to send/i })).toBeDisabled(); }); it("shows invite email field when inviteChatboxMember is wired (e.g. saved chatbox id)", () => { @@ -164,6 +180,7 @@ describe("SetupChecklistPanel", () => { )!.createDraft("openai/gpt-5-mini"); render( void; onOpenAddServer: () => void; onToggleServer: (serverId: string, checked: boolean) => void; + stagedAccessInviteEmail: string; + onStagedAccessInviteEmailChange: (email: string) => void; onCloseMobile?: () => void; inviteChatboxMember?: (email: string) => Promise; }) { @@ -405,7 +409,6 @@ export function SetupChecklistPanel({ const [openMap, setOpenMap] = useState< Partial> >({}); - const [accessInviteEmail, setAccessInviteEmail] = useState(""); const [accessInviteBusy, setAccessInviteBusy] = useState(false); const didAutoExpandRef = useRef(false); @@ -447,13 +450,13 @@ export function SetupChecklistPanel({ const handleAccessInvite = async () => { if (!inviteChatboxMember) return; - const normalized = accessInviteEmail.trim().toLowerCase(); + const normalized = stagedAccessInviteEmail.trim().toLowerCase(); if (!normalized) return; setAccessInviteBusy(true); try { await inviteChatboxMember(normalized); toast.success(`Invited ${normalized}`); - setAccessInviteEmail(""); + onStagedAccessInviteEmailChange(""); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to send invite", @@ -640,14 +643,18 @@ export function SetupChecklistPanel({ chatboxDraft.mode, chatboxDraft.allowGuestAccess, )} - onValueChange={(value) => + onValueChange={(value) => { + const nextSettings = settingsFromChatboxAccessPreset( + value as ChatboxAccessPreset, + ); onDraftChange((draft) => ({ ...draft, - ...settingsFromChatboxAccessPreset( - value as ChatboxAccessPreset, - ), - })) - } + ...nextSettings, + })); + if (nextSettings.mode !== "invited_only") { + onStagedAccessInviteEmailChange(""); + } + }} className="grid gap-2" >