From d1bdf6f44aa480b3075f2172822d4864e1acc20f Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 2 Oct 2026 09:36:36 +0200 Subject: [PATCH] Claude sign-up returns to the authorization; Etsy adapter fixes; trial card reads its limits Sign-up from an AI client: since the Claude directory listing went live, 277 'Connect' clicks led to 97 sign-ups but only 21 completed authorizations. The 'Create an account' link on the MCP login page did not lead back: after verifying, the new user landed on the trial offer and the welcome wizard, and the connection stayed half done until they started over from Claude. - The link now carries redirect=/auth/login; the login page leaves Next with a full navigation for /auth/* targets and, when returning to an authorization, skips the trial card and goes straight back to Approve (trial is created by verification anyway). - The pending authorization lives 30 minutes instead of 10 (oauthSessionExpiresIn), long enough to create an account and verify. Etsy (12 installs in 10 days, most failing): etsy_get_listing 'includes' lists only what Etsy v3 accepts (Inventory/Shipping were refused); etsy_get_user_shops says where user_id comes from (minimum 1, '0' was sent); the public listings_active explains its paging cap and points to a new authenticated etsy_get_listings_by_shop (all states, Inventory/Shipping includes), all checked against Etsy's OpenAPI spec. Description/parameter changes reach installed connectors through the catalog reconciler; the new tool shows as an update. The 'Trial Activated' card read 'Up to 2 connectors' after the trial moved to 5/3; it now reads the limits from the licence. Tests: login.controller sign-up link (cloud/self-hosted), e2e 'Claude Connect' sign-up returns to /auth/login and off-site redirects are ignored; full backend suite (6321) and all 85 e2e pass. --- packages/backend/src/adapters/intl/etsy.json | 90 +++++++++++++++++-- packages/backend/src/app.module.ts | 5 ++ .../backend/src/auth/login.controller.spec.ts | 33 +++++++ packages/backend/src/auth/login.controller.ts | 8 +- packages/frontend/src/app/login/page.tsx | 64 +++++++++---- .../e2e/neutral-signup-and-billing.spec.ts | 60 ++++++++++++- 6 files changed, 235 insertions(+), 25 deletions(-) diff --git a/packages/backend/src/adapters/intl/etsy.json b/packages/backend/src/adapters/intl/etsy.json index 27b7dcde..9178ecf8 100644 --- a/packages/backend/src/adapters/intl/etsy.json +++ b/packages/backend/src/adapters/intl/etsy.json @@ -48,13 +48,14 @@ }, { "name": "etsy_get_user_shops", - "description": "List shops owned by the user. Returns shop_id, shop_name, currency_code, languages, login_name, last_updated_tsz, listing_active_count.", + "description": "List shops owned by a user. Returns shop_id, shop_name, currency_code, languages, login_name, last_updated_tsz, listing_active_count. For the connected account, take user_id from etsy_get_authenticated_user first.", "parameters": { "type": "object", "properties": { "user_id": { "type": "integer", - "description": "User ID." + "description": "Etsy user ID (a positive number): use the user_id returned by etsy_get_authenticated_user.", + "minimum": 1 } }, "required": [ @@ -88,7 +89,7 @@ }, { "name": "etsy_get_shop_listings_active", - "description": "List active listings in a shop.", + "description": "Public list of a shop's active listings, as any visitor sees them. Etsy caps how far this public view can page (large offsets are refused); to go through all of your own shop's listings, or drafts and inactive ones, use etsy_get_listings_by_shop.", "parameters": { "type": "object", "properties": { @@ -98,11 +99,14 @@ }, "limit": { "type": "integer", - "description": "Per page (max 100)." + "description": "Per page, 1 to 100 (default 25).", + "minimum": 1, + "maximum": 100 }, "offset": { "type": "integer", - "description": "Pagination offset." + "description": "Pagination offset (0 = first page). Keep it small: Etsy refuses large offsets on this public endpoint.", + "minimum": 0 }, "sort_on": { "type": "string", @@ -133,6 +137,80 @@ } } }, + { + "name": "etsy_get_listings_by_shop", + "description": "Listings of your own shop by state (active, inactive, sold_out, draft, removed, expired), as the shop owner sees them. Authenticated, so it pages through the whole catalogue, and it can include Inventory and Shipping. Use it rather than etsy_get_shop_listings_active for your own shop.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "minimum": 1, + "description": "Shop ID (from etsy_get_user_shops)." + }, + "state": { + "type": "string", + "enum": [ + "active", + "inactive", + "sold_out", + "draft", + "removed", + "expired" + ], + "description": "Listing state. Default: active." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Per page, 1 to 100 (default 25)." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Pagination offset (0 = first page)." + }, + "sort_on": { + "type": "string", + "enum": [ + "created", + "price", + "updated", + "score" + ], + "description": "Sort field." + }, + "sort_order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort direction." + }, + "includes": { + "type": "string", + "description": "Optional extra data, comma-separated, any of: Shipping, Images, Shop, User, Translations, Inventory, Videos, Personalization, BuyerPrice." + } + }, + "required": [ + "shop_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/listings", + "queryParams": { + "state": "$state", + "limit": "$limit", + "offset": "$offset", + "sort_on": "$sort_on", + "sort_order": "$sort_order", + "includes": "$includes" + } + } + }, { "name": "etsy_get_listing", "description": "Fetch one listing by listing_id with full details.", @@ -145,7 +223,7 @@ }, "includes": { "type": "string", - "description": "Comma-separated: Shipping, Images, Shop, User, Translations, Inventory, Videos." + "description": "Optional extra data, comma-separated, any of: Images, Shop, User, Translations, Videos, Personalization, BuyerPrice. Etsy rejects anything else: inventory (SKUs, quantities, prices per variation) is not available here, and shipping comes with the shop's shipping profiles, not with the listing." } }, "required": [ diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index cec34d5d..968ac148 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -92,6 +92,11 @@ if (useOAuth) { // always mounts cookie-parser (see the regression test in // main-cookie-parser.spec.ts), so the check is skipped only then. skipCookieParserCheck: Sentry.isInitialized(), + // How long a pending authorization (an AI client's "Connect" click) + // waits for the user. The default 10 minutes is too short for someone + // without an account: from the login page they create one, verify their + // email and come back to approve. 30 minutes covers that comfortably. + oauthSessionExpiresIn: 30 * 60 * 1000, }), ); } diff --git a/packages/backend/src/auth/login.controller.spec.ts b/packages/backend/src/auth/login.controller.spec.ts index 99697061..2b9e2b1d 100644 --- a/packages/backend/src/auth/login.controller.spec.ts +++ b/packages/backend/src/auth/login.controller.spec.ts @@ -170,6 +170,39 @@ describe('LoginController', () => { expect(res._sent).not.toContain(''); }); + it('offers sign-up on cloud, returning to this page after verification', async () => { + const cloud = new LoginController( + authService as unknown as AuthService, + prisma as unknown as PrismaService, + config as unknown as ConfigService, + store as unknown as PrismaOAuthStore, + sso as unknown as SsoService, + { mode: 'cloud', isCloud: () => true, isSelfHosted: () => false } as any, + grants as any, + ); + const res = makeRes(); + await cloud.showLoginPage( + undefined as unknown as string, + undefined as unknown as string, + makeReq({ cookies: {} }), + res, + ); + expect(res._sent).toContain('href="/login?mode=register&redirect=%2Fauth%2Flogin"'); + expect(res._sent).toContain('href="/forgot-password"'); + }); + + it('offers no sign-up on self-hosted (registration is invite-based there)', async () => { + const res = makeRes(); + await controller.showLoginPage( + undefined as unknown as string, + undefined as unknown as string, + makeReq({ cookies: {} }), + res, + ); + expect(res._sent).not.toContain('mode=register'); + expect(res._sent).toContain('href="/forgot-password"'); + }); + it('falls back to a generic form (no consent block) without a session', async () => { const res = makeRes(); await controller.showLoginPage( diff --git a/packages/backend/src/auth/login.controller.ts b/packages/backend/src/auth/login.controller.ts index c5d70ed9..3e54838c 100644 --- a/packages/backend/src/auth/login.controller.ts +++ b/packages/backend/src/auth/login.controller.ts @@ -791,14 +791,18 @@ export class LoginController { // without an account would otherwise hit a dead end: the only way off this // page was "Use a different account". Give them the way in. The links point // at the dashboard app on the same origin; sign-up is cloud-only (on - // self-hosted, registration is closed or invite-based). + // self-hosted, registration is closed or invite-based). Sign-up carries + // `redirect=/auth/login`, so after verifying their email the new user + // lands back here, still inside the pending authorization (its cookie + // outlives the detour), and approves with one click instead of starting + // over from their AI client. const preAuthLinks = sessionUser ? '' : `

Forgot your password?

` + (this.deployment.isCloud() ? ` -

New to AnythingMCP? Create an account

` +

New to AnythingMCP? Create an account

` : ''); // Signed in to the dashboard already: approve as that account, or switch. diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index bbc4d038..1f204db1 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -74,12 +74,36 @@ function LoginForm() { const [registrationEnabled, setRegistrationEnabled] = useState(false); const [isCloudMode, setIsCloudMode] = useState(false); const [trialDaysLeft, setTrialDaysLeft] = useState(0); + // What the trial actually grants, read from the licence once it exists, so + // this card cannot drift from the plan (it said "2 connectors" after the + // trial moved to 5). + const [trialLimits, setTrialLimits] = useState<{ connectors: number; servers: number; users: number } | null>(null); + useEffect(() => { + if (setupStep !== 'trial-activated' || !authToken) return; + license + .getStatus(authToken) + .then((st) => { + const f = st?.features ?? {}; + if (typeof f.maxConnectors === 'number' && typeof f.maxMcpServers === 'number') { + setTrialLimits({ connectors: f.maxConnectors, servers: f.maxMcpServers, users: f.maxUsers ?? 1 }); + } + }) + .catch(() => {}); + }, [setupStep, authToken]); const router = useRouter(); const searchParams = useSearchParams(); const { login } = useAuth(); const toast = useToast(); const redirectTo = safeRedirect(searchParams.get('redirect')); + // Back into an AI client's pending authorization (/auth/login, served by the + // backend): someone who came from "Connect" in Claude and had to create an + // account first. Not a Next route, so it needs a full navigation. + const returningToAuthorization = redirectTo.startsWith('/auth/'); + const goTo = (target: string) => { + if (target.startsWith('/auth/')) window.location.assign(target); + else router.push(target); + }; const emailVerifiedParam = searchParams.get('emailVerified'); const modeParam = searchParams.get('mode'); // 'register' or 'login' const ssoCode = searchParams.get('sso'); @@ -279,7 +303,10 @@ function LoginForm() { setSetupStep('verify-email'); } else { login(result.accessToken, result.user); - if (isCloudMode && needsLicenseSetup) { + if (isCloudMode && needsLicenseSetup && returningToAuthorization) { + await license.activateTrial(result.accessToken).catch(() => undefined); + goTo(redirectTo); + } else if (isCloudMode && needsLicenseSetup) { // Cloud mode: auto-activate trial for verified users setAuthToken(result.accessToken); try { @@ -289,19 +316,19 @@ function LoginForm() { // that already holds a licence (a running trial, a paid plan) goes // straight in, instead of being told it has "0 days" left. if (!trialResult.trialStarted) { - router.push(redirectTo); + goTo(redirectTo); return; } setTrialDaysLeft(trialResult.trialDaysLeft); setSetupStep('trial-activated'); } catch { - router.push(redirectTo); + goTo(redirectTo); } } else if (needsLicenseSetup) { setAuthToken(result.accessToken); setSetupStep('license-choice'); } else { - router.push(redirectTo); + goTo(redirectTo); } } } catch (err: any) { @@ -323,7 +350,11 @@ function LoginForm() { const verifiedUser = { ...storedUser, emailVerified: true }; login(authToken, verifiedUser); - if (isCloudMode) { + if (isCloudMode && returningToAuthorization) { + // Verification already created the trial; the user is in the middle + // of connecting an AI client, so take them straight back to approve. + goTo(redirectTo); + } else if (isCloudMode) { // Cloud mode: auto-activate trial try { const trialResult = await license.activateTrial(authToken); @@ -332,12 +363,12 @@ function LoginForm() { setSetupStep('trial-activated'); } catch (trialErr: any) { // Trial may already exist (e.g. returning user) — go to dashboard - router.push(redirectTo); + goTo(redirectTo); } } else if (isFirstUserFlag) { setSetupStep('license-choice'); } else { - router.push(redirectTo); + goTo(redirectTo); } } catch (err: any) { setError(err.message || 'Invalid verification code'); @@ -386,7 +417,7 @@ function LoginForm() { setLoading(true); try { await license.startBusinessTrial(authToken); - router.push(redirectTo); + goTo(redirectTo); } catch (err: any) { setError(err.message || 'Could not start the trial'); setLoading(false); @@ -402,7 +433,7 @@ function LoginForm() { setLoading(true); try { await license.setKey(licenseKey, authToken); - router.push(redirectTo); + goTo(redirectTo); } catch (err: any) { setError(err.message || 'Failed to activate license'); setLoading(false); @@ -410,7 +441,7 @@ function LoginForm() { }; const handleSkip = () => { - router.push(redirectTo); + goTo(redirectTo); }; // ── Email Verification Step ───────────────────────────────────────────── @@ -482,7 +513,7 @@ function LoginForm() { if (isFirstUserFlag) { setSetupStep('license-choice'); } else { - router.push(redirectTo); + goTo(redirectTo); } }} className="text-sm text-[var(--text-2)] hover:text-[var(--brand)] hover:underline" @@ -568,13 +599,16 @@ function LoginForm() {

YOUR TRIAL INCLUDES

- diff --git a/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts b/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts index 94958404..2e3fee8c 100644 --- a/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts +++ b/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts @@ -17,8 +17,8 @@ const CLOUD_INFO = { const NEUTRAL = { verificationRequired: true, message: 'Check your inbox.' }; -async function fillSignup(page: Page, email: string) { - await page.goto('/login?mode=register'); +async function fillSignup(page: Page, email: string, query = '') { + await page.goto(`/login?mode=register${query}`); await expect(page.locator('#auth-name')).toBeVisible(); await page.locator('#auth-name').fill('Jane'); await page.locator('#auth-email').fill(email); @@ -79,6 +79,62 @@ test.describe('cloud sign-up', () => { }); }); +test.describe('sign-up from an AI client (Claude "Connect")', () => { + // The MCP authorization page links "Create an account" with + // redirect=/auth/login. After verifying, the new user must land back on that + // page (still inside the pending authorization) to approve with one click, + // not on the trial offer or the welcome wizard, which used to strand the + // connection half way. + test('after verifying the email, goes straight back to the authorization page', async ({ page }) => { + const calls: string[] = []; + await page.route(/\/(api|health)\//, async (route) => { + const p = new URL(route.request().url()).pathname; + calls.push(p); + if (p === '/health/server-info') return route.fulfill({ json: CLOUD_INFO }); + if (p === '/api/auth/register') return route.fulfill({ status: 201, json: NEUTRAL }); + if (p === '/api/auth/login') { + return route.fulfill({ + json: { + accessToken: 't', + user: { id: 'u1', email: 'claude@example.test', name: 'Jane', role: 'ADMIN', organizationId: 'o1', emailVerified: false }, + needsLicenseSetup: true, + }, + }); + } + if (p === '/api/auth/verify-email') return route.fulfill({ json: { message: 'ok', emailVerified: true } }); + if (p === '/api/license/activate-trial') { + return route.fulfill({ json: { trialStarted: false, trialDaysLeft: 7, plan: 'trial', expiresAt: null, licenseKey: 'k', message: '' } }); + } + return route.fulfill({ json: {} }); + }); + // The backend-served authorization page, stubbed. + await page.route((url) => url.pathname === '/auth/login', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: '

Authorize AnythingMCP

' }), + ); + + await fillSignup(page, 'claude@example.test', '&redirect=%2Fauth%2Flogin'); + await expect(page.getByRole('heading', { name: 'Verify Your Email' })).toBeVisible(); + await page.locator('input[inputmode="numeric"]').fill('123456'); + await page.getByRole('button', { name: 'Verify Email' }).click(); + + await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Authorize AnythingMCP' })).toBeVisible(); + expect(calls).toContain('/api/auth/verify-email'); + // Not detoured through the trial offer or the welcome wizard. + expect(calls.some((c) => c.includes('checkout-link'))).toBe(false); + }); + + test('an off-site redirect is ignored', async ({ page }) => { + await page.route(/\/(api|health)\//, (route) => { + const p = new URL(route.request().url()).pathname; + if (p === '/health/server-info') return route.fulfill({ json: CLOUD_INFO }); + return route.fulfill({ json: {} }); + }); + await page.goto('/login?mode=register&redirect=https%3A%2F%2Fevil.example%2Fauth%2Flogin'); + await expect(page.locator('#auth-name')).toBeVisible(); + }); +}); + const ADMIN = { id: 'u1', email: 'owner@example.test',