Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions mcpjam-inspector/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useConvexAuth } from "convex/react";
import { useConvexAuth, useQuery } from "convex/react";
import {
useCallback,
useEffect,
Expand Down Expand Up @@ -81,6 +81,7 @@ import {
} from "./lib/theme-utils";
import CompletingSignInLoading from "./components/CompletingSignInLoading";
import LoadingScreen from "./components/LoadingScreen";
import { OccupationGate } from "./components/signup/OccupationGate";
import { Header } from "./components/Header";
import { ThemePreset } from "./types/preferences/theme";
import type {
Expand Down Expand Up @@ -344,6 +345,10 @@ export default function App() {
isLoading: isWorkOsLoading,
} = useAuth();
const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth();
const currentUser = useQuery(
"users:getCurrentUser" as any,
isAuthenticated ? ({} as any) : "skip",
);
const [hostedOAuthHandling, setHostedOAuthHandling] = useState(() => {
if (!HOSTED_MODE) {
return false;
Expand Down Expand Up @@ -596,7 +601,7 @@ export default function App() {
// Set up Electron OAuth callback handling
useElectronOAuth();
// Ensure a `users` row exists after Convex auth
useEnsureDbUser();
const { isEnsuringUser } = useEnsureDbUser();

const isDebugCallback = window.location.pathname.startsWith(
"/oauth/callback/debug",
Expand Down Expand Up @@ -1792,6 +1797,28 @@ export default function App() {
return <LoadingScreen />;
}

if (
!isHostedChatRoute &&
isAuthenticated &&
(currentUser === undefined || currentUser === null || isEnsuringUser)
) {
return <LoadingScreen />;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (
!isHostedChatRoute &&
isAuthenticated &&
currentUser?.occupationRequired === true &&
!currentUser?.occupation?.trim()
) {
return (
<OccupationGate
userId={workOsUser?.id ?? null}
email={workOsUser?.email}
/>
);
}

const shouldShowActiveServerSelector =
activeTab === "tools" ||
activeTab === "resources" ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@
}));

vi.mock("../hooks/useEnsureDbUser", () => ({
useEnsureDbUser: vi.fn(),
useEnsureDbUser: vi.fn(() => ({ isEnsuringUser: false })),
}));

vi.mock("../hooks/usePostHogIdentify", () => ({
Expand Down Expand Up @@ -577,7 +577,7 @@

expect(
screen.queryByTestId("hosted-oauth-loading")
).not.toBeInTheDocument();

Check failure on line 580 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > does not keep the hosted loading screen for workspace OAuth callbacks

Error: expect(element).not.toBeInTheDocument() expected document not to contain element, found <div data-testid="hosted-oauth-loading" /> instead ❯ src/__tests__/App.hosted-oauth.test.tsx:580:11
expect(screen.getByText("Servers Tab")).toBeInTheDocument();

await waitFor(() => {
Expand Down Expand Up @@ -880,7 +880,7 @@
render(<App />);

await waitFor(() => {
expect(mockChatboxesTab).toHaveBeenCalled();

Check failure on line 883 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > passes a billing-safe workspace id to the chatboxes tab

AssertionError: expected "spy" to be called at least once Ignored nodes: comments, script, style <html> <head /> <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> </html> ❯ src/__tests__/App.hosted-oauth.test.tsx:883:32 ❯ runWithExpensiveErrorDiagnosticsDisabled ../../node_modules/@testing-library/dom/dist/config.js:47:12 ❯ checkCallback ../../node_modules/@testing-library/dom/dist/wait-for.js:124:77 ❯ Timeout.checkRealTimersCallback ../../node_modules/@testing-library/dom/dist/wait-for.js:118:16
});

const lastCall =
Expand Down Expand Up @@ -1033,7 +1033,7 @@
render(<App />);

await waitFor(() => {
expect(mockMCPSidebar).toHaveBeenCalled();

Check failure on line 1036 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > keeps the sidebar-selected org active when navigating back to servers

AssertionError: expected "spy" to be called at least once Ignored nodes: comments, script, style <html> <head /> <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> </html> ❯ src/__tests__/App.hosted-oauth.test.tsx:1036:30 ❯ runWithExpensiveErrorDiagnosticsDisabled ../../node_modules/@testing-library/dom/dist/config.js:47:12 ❯ checkCallback ../../node_modules/@testing-library/dom/dist/wait-for.js:124:77 ❯ Timeout.checkRealTimersCallback ../../node_modules/@testing-library/dom/dist/wait-for.js:118:16
});

const getLastSidebarProps = () =>
Expand Down Expand Up @@ -1111,7 +1111,7 @@
render(<App />);

await waitFor(() => {
expect(mockMCPSidebar).toHaveBeenCalled();

Check failure on line 1114 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > preserves the newly selected org when navigating away immediately

AssertionError: expected "spy" to be called at least once Ignored nodes: comments, script, style <html> <head /> <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> </html> ❯ src/__tests__/App.hosted-oauth.test.tsx:1114:30 ❯ runWithExpensiveErrorDiagnosticsDisabled ../../node_modules/@testing-library/dom/dist/config.js:47:12 ❯ checkCallback ../../node_modules/@testing-library/dom/dist/wait-for.js:124:77 ❯ Timeout.checkRealTimersCallback ../../node_modules/@testing-library/dom/dist/wait-for.js:118:16
});

const getLastSidebarProps = () =>
Expand Down Expand Up @@ -1225,7 +1225,7 @@
render(<App />);

await waitFor(() => {
expect(mockMCPSidebar).toHaveBeenCalled();

Check failure on line 1228 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > disables sidebar workspace creation when the routed org is free and at cap

AssertionError: expected "spy" to be called at least once Ignored nodes: comments, script, style <html> <head /> <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> </html> ❯ src/__tests__/App.hosted-oauth.test.tsx:1228:30 ❯ runWithExpensiveErrorDiagnosticsDisabled ../../node_modules/@testing-library/dom/dist/config.js:47:12 ❯ checkCallback ../../node_modules/@testing-library/dom/dist/wait-for.js:124:77 ❯ Timeout.checkRealTimersCallback ../../node_modules/@testing-library/dom/dist/wait-for.js:118:16
});

const lastCall =
Expand Down Expand Up @@ -1304,7 +1304,7 @@

render(<App />);

await waitFor(() => {

Check failure on line 1307 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > restores the billing callback back into the billing flow when session intent exists

TestingLibraryElementError: Unable to find an element by: [data-testid="billing-handoff-overlay"] Ignored nodes: comments, script, style <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> Ignored nodes: comments, script, style <html> <head /> <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> </html> ❯ Proxy.waitForWrapper ../../node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ src/__tests__/App.hosted-oauth.test.tsx:1307:11
expect(replaceStateSpy).toHaveBeenCalledWith({}, "", "/billing");
expect(screen.getByTestId("billing-handoff-overlay")).toBeInTheDocument();
});
Expand All @@ -1322,7 +1322,7 @@

render(<App />);

await waitFor(() => {

Check failure on line 1325 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > falls back to the default callback destination when billing session intent is missing

TestingLibraryElementError: Unable to find an element with the text: Servers Tab. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> Ignored nodes: comments, script, style <html> <head /> <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> </html> ❯ Proxy.waitForWrapper ../../node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ src/__tests__/App.hosted-oauth.test.tsx:1325:11
expect(replaceStateSpy).toHaveBeenCalledWith({}, "", "/");
expect(screen.getByText("Servers Tab")).toBeInTheDocument();
});
Expand Down Expand Up @@ -1358,7 +1358,7 @@

render(<App />);

await waitFor(() => {

Check failure on line 1361 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > keeps a persisted billing resume alive when /billing returns without query params

TestingLibraryElementError: Unable to find an element by: [data-testid="billing-handoff-overlay"] Ignored nodes: comments, script, style <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> Ignored nodes: comments, script, style <html> <head /> <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> </html> ❯ Proxy.waitForWrapper ../../node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ src/__tests__/App.hosted-oauth.test.tsx:1361:11
expect(screen.getByTestId("billing-handoff-overlay")).toBeInTheDocument();
expect(mockOrganizationsTab).toHaveBeenCalled();
});
Expand Down Expand Up @@ -1440,7 +1440,7 @@

render(<App />);

expect(screen.getByText("Preparing checkout...")).toBeInTheDocument();

Check failure on line 1443 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > keeps billing resume behind the checkout spinner for signed-in users

TestingLibraryElementError: Unable to find an element with the text: Preparing checkout.... This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/config.js:37:19 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:76:38 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:52:17 ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:95:19 ❯ src/__tests__/App.hosted-oauth.test.tsx:1443:19

await waitFor(() => {
expect(screen.getByTestId("billing-handoff-overlay")).toBeInTheDocument();
Expand Down Expand Up @@ -1513,7 +1513,7 @@

render(<App />);

await waitFor(() => {

Check failure on line 1516 in mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

src/__tests__/App.hosted-oauth.test.tsx > App hosted OAuth callback handling > drops the billing overlay when checkout intent is consumed

TestingLibraryElementError: Unable to find an element by: [data-testid="billing-handoff-overlay"] Ignored nodes: comments, script, style <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> Ignored nodes: comments, script, style <html> <head /> <body> <div> <div data-testid="hosted-oauth-loading" /> </div> </body> </html> ❯ Proxy.waitForWrapper ../../node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ src/__tests__/App.hosted-oauth.test.tsx:1516:11
expect(screen.getByTestId("billing-handoff-overlay")).toBeInTheDocument();
expect(screen.getByTestId("consume-checkout-intent")).toBeInTheDocument();
});
Expand Down
130 changes: 130 additions & 0 deletions mcpjam-inspector/client/src/components/signup/OccupationGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { FormEvent, useState } from "react";
import { useMutation } from "convex/react";
import { usePostHog } from "posthog-js/react";
import { ArrowRight, Check, Loader2 } from "lucide-react";
import { Button } from "@mcpjam/design-system/button";
import { standardEventProps } from "@/lib/PosthogUtils";
import { cn } from "@/lib/utils";

const OCCUPATION_SUGGESTIONS = [
"Software Engineer",
"Product Manager",
"Engineering Manager",
"Platform Engineer",
"Other",
] as const;

type Occupation = (typeof OCCUPATION_SUGGESTIONS)[number];

interface OccupationGateProps {
userId?: string | null;
email?: string | null;
}

export function OccupationGate({ userId, email }: OccupationGateProps) {
const posthog = usePostHog();
const updateOccupation = useMutation("users:updateOccupation" as any);
const [selected, setSelected] = useState<Occupation | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

const canSubmit = selected !== null && !isSubmitting;

const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selected) {
setError("Pick a role to continue.");
return;
}

setIsSubmitting(true);
setError(null);

try {
await updateOccupation({ occupation: selected });
posthog.setPersonProperties({ occupation: selected });
posthog.capture("signup_occupation_submitted", {
...standardEventProps("signup_occupation_gate"),
occupation: selected,
});
posthog.register({ occupation: selected });
} catch (err) {
console.error("[signup] Failed to save occupation", err);
setError("Could not save your occupation. Please try again.");
} finally {
setIsSubmitting(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
};

return (
<main className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-lg flex-col justify-center px-6 py-12">
<div className="mb-10">
<img src="/mcp_jam.svg" alt="MCPJam" className="mb-8 h-9 w-auto" />
<h1 className="text-3xl font-semibold tracking-normal">
What is your role?
</h1>
<p className="mt-3 text-sm text-muted-foreground">
This helps us understand who is using MCPJam.
</p>
</div>

<form onSubmit={handleSubmit}>
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Select one
</p>

<div className="flex flex-wrap gap-2">
{OCCUPATION_SUGGESTIONS.map((suggestion) => {
const isSelected = selected === suggestion;
return (
<button
key={suggestion}
type="button"
onClick={() => {
setSelected(suggestion);
if (error) setError(null);
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className={cn(
"flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm font-medium transition-all duration-150",
isSelected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-background text-foreground hover:-translate-y-px hover:border-primary hover:bg-primary hover:text-primary-foreground hover:shadow-md",
)}
>
{isSelected && <Check className="h-3.5 w-3.5" />}
{suggestion}
</button>
);
})}
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

{error ? (
<p className="mt-3 text-sm text-destructive">{error}</p>
) : null}

<hr className="my-6 border-border" />

<Button type="submit" disabled={!canSubmit} className="w-full">
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
Continue
</Button>
</form>

{email ? (
<p className="mt-6 text-xs text-muted-foreground">
Signed in as {email}
</p>
) : userId ? (
<p className="mt-6 text-xs text-muted-foreground">
Signed in to MCPJam
</p>
) : null}
</div>
</main>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const mockState = vi.hoisted(() => ({
convexAuth: {
isAuthenticated: false,
},
convexUser: null as { occupation?: string } | null,
detectPlatform: vi.fn(() => "mac"),
}));

Expand All @@ -32,6 +33,7 @@ vi.mock("@workos-inc/authkit-react", () => ({

vi.mock("convex/react", () => ({
useConvexAuth: () => mockState.convexAuth,
useQuery: () => mockState.convexUser,
}));

vi.mock("@/lib/PosthogUtils", () => ({
Expand All @@ -44,6 +46,7 @@ describe("usePostHogIdentify", () => {
vi.stubGlobal("__APP_VERSION__", "2.0.13-test");
mockState.auth.user = null;
mockState.convexAuth.isAuthenticated = false;
mockState.convexUser = null;
mockState.detectPlatform.mockReturnValue("mac");
});

Expand Down Expand Up @@ -114,4 +117,25 @@ describe("usePostHogIdentify", () => {
});
expect(mockState.posthog.identify).not.toHaveBeenCalled();
});

it("adds occupation when the Convex user has one", () => {
mockState.auth.user = {
id: "user_123",
email: "user@example.com",
firstName: "Taylor",
lastName: "Smith",
};
mockState.convexAuth.isAuthenticated = true;
mockState.convexUser = { occupation: "Platform Engineer" };

renderHook(() => usePostHogIdentify());

expect(mockState.posthog.identify).toHaveBeenCalledWith("user_123", {
email: "user@example.com",
name: "Taylor Smith",
first_name: "Taylor",
last_name: "Smith",
occupation: "Platform Engineer",
});
});
});
25 changes: 20 additions & 5 deletions mcpjam-inspector/client/src/hooks/useEnsureDbUser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useMutation, useConvexAuth } from "convex/react";
import { useAuth } from "@workos-inc/authkit-react";
import * as Sentry from "@sentry/react";
Expand All @@ -13,25 +13,35 @@ export function useEnsureDbUser() {
const { isAuthenticated, isLoading } = useConvexAuth();
const ensureUser = useMutation("users:ensureUser" as any);
const lastEnsuredUserIdRef = useRef<string | null>(null);
const [isEnsuringUser, setIsEnsuringUser] = useState(false);

// Reset cache on logout so we re-run for the next login in the same session
useEffect(() => {
if (!isAuthenticated) {
lastEnsuredUserIdRef.current = null;
setIsEnsuringUser(false);
Sentry.setUser(null); // Clear Sentry user on logout
}
}, [isAuthenticated]);

useEffect(() => {
if (isLoading) return;
if (isLoading) {
return;
}
// WorkOS user hydration can briefly lead Convex auth. This is expected
// during callback completion; wait for isAuthenticated instead of throwing.
if (!isAuthenticated) return;
if (!user) return;
if (!isAuthenticated || !user) {
setIsEnsuringUser(false);
return;
}

// Only (re)ensure when the authenticated WorkOS user changes.
if (lastEnsuredUserIdRef.current === user.id) return;
if (lastEnsuredUserIdRef.current === user.id) {
setIsEnsuringUser(false);
return;
}

setIsEnsuringUser(true);
ensureUser()
.then((id: string | null) => {
// eslint-disable-next-line no-console
Expand All @@ -44,6 +54,11 @@ export function useEnsureDbUser() {
console.error("[auth] ensureUser failed", err);
// allow retry next effect pass
lastEnsuredUserIdRef.current = null;
})
.finally(() => {
setIsEnsuringUser(false);
});
}, [isAuthenticated, isLoading, user, ensureUser]);

return { isEnsuringUser };
}
21 changes: 15 additions & 6 deletions mcpjam-inspector/client/src/hooks/usePostHogIdentify.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { usePostHog } from "posthog-js/react";
import { useAuth } from "@workos-inc/authkit-react";
import { useConvexAuth } from "convex/react";
import { useConvexAuth, useQuery } from "convex/react";
import { detectPlatform } from "@/lib/PosthogUtils";

/**
Expand All @@ -12,23 +12,32 @@ export function usePostHogIdentify() {
const posthog = usePostHog();
const { user } = useAuth();
const { isAuthenticated } = useConvexAuth();
const convexUser = useQuery(
"users:getCurrentUser" as any,
isAuthenticated ? ({} as any) : "skip"
);

useEffect(() => {
if (!posthog) return;

// User is authenticated - identify them
if (isAuthenticated && user) {
// Identify the user with their WorkOS ID
posthog.identify(user.id, {
const personProperties: Record<string, string | null | undefined> = {
email: user.email,
name:
user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.email,
first_name: user.firstName,
last_name: user.lastName,
// Add any other user properties you want to track
});
};

if (convexUser?.occupation) {
personProperties.occupation = convexUser.occupation;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Identify the user with their WorkOS ID
posthog.identify(user.id, personProperties);

posthog.register({
user_id: user.id,
Expand All @@ -43,5 +52,5 @@ export function usePostHogIdentify() {
version: __APP_VERSION__,
});
}
}, [posthog, isAuthenticated, user]);
}, [posthog, isAuthenticated, user, convexUser]);
}
Loading