diff --git a/packages/backend/src/adapters/connector-setup.service.spec.ts b/packages/backend/src/adapters/connector-setup.service.spec.ts index 757e688e..5b5fd892 100644 --- a/packages/backend/src/adapters/connector-setup.service.spec.ts +++ b/packages/backend/src/adapters/connector-setup.service.spec.ts @@ -145,6 +145,34 @@ describe('ConnectorSetupService — install', () => { expect(out.isError).toBe(true); expect(adapters.importAdapter).not.toHaveBeenCalled(); }); + + it('at the limit, offers an admin on the free trial the card trial', async () => { + const { service, ctx, licenseGuard } = build({ role: 'ADMIN' }); + licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Trial limit reached (2 connectors).')); + licenseGuard.getUsage.mockResolvedValueOnce({ plan: 'trial', connectors: { current: 2, max: 2 } }); + const out: any = await service.install(ctx, { adapter: 'openplz' }); + expect(out.body).toMatchObject({ + error: 'Trial limit reached (2 connectors).', + upgradeUrl: 'https://cloud.example.com/start-trial', + }); + expect(out.body.whatTheUserCanDo).toMatch(/nothing is charged before the trial ends/); + }); + + it('at the limit, sends an admin on a paid plan to the licence page', async () => { + const { service, ctx, licenseGuard } = build({ role: 'ADMIN' }); + licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Connector limit reached.')); + licenseGuard.getUsage.mockResolvedValueOnce({ plan: 'cloud_starter', connectors: { current: 5, max: 5 } }); + const out: any = await service.install(ctx, { adapter: 'openplz' }); + expect(out.body.upgradeUrl).toBe('https://cloud.example.com/settings/license'); + }); + + it('at the limit, tells an editor who can lift it, without a billing link', async () => { + const { service, ctx, licenseGuard } = build({ role: 'EDITOR' }); + licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Trial limit reached (2 connectors).')); + const out: any = await service.install(ctx, { adapter: 'openplz' }); + expect(out.body.upgradeUrl).toBeUndefined(); + expect(out.body.whatTheUserCanDo).toMatch(/administrator/); + }); }); describe('ConnectorSetupService — links', () => { diff --git a/packages/backend/src/adapters/connector-setup.service.ts b/packages/backend/src/adapters/connector-setup.service.ts index b4a70f8b..79e985a6 100644 --- a/packages/backend/src/adapters/connector-setup.service.ts +++ b/packages/backend/src/adapters/connector-setup.service.ts @@ -171,7 +171,7 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit try { await this.licenseGuard.checkCanCreateConnector(ctx.userId, ctx.organizationId); } catch (err: any) { - return { isError: true, body: { error: String(err?.message ?? err), dashboard: `${ctx.dashboardBase}/settings/license` } }; + return { isError: true, body: await this.planLimitAnswer(ctx, String(err?.message ?? err)) }; } let imported: Awaited>; @@ -227,6 +227,34 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit }; } + /** + * The plan's connector limit stopped an install. Billing is mentioned here + * and only here: the answer is the reason the request failed, with the one + * page that lifts the limit. An admin on the free trial gets the card-trial + * offer (nothing charged before the trial ends); other admins the licence + * page; members are told who can do it. + */ + private async planLimitAnswer(ctx: SetupContext, reason: string): Promise> { + const member = await this.prisma.organizationMember + .findFirst({ + where: { userId: ctx.userId, organizationId: ctx.organizationId, deactivatedAt: null }, + select: { role: true }, + }) + .catch(() => null); + if (member?.role !== 'ADMIN') { + return { error: reason, whatTheUserCanDo: 'Ask a workspace administrator to upgrade the plan, or remove a connector they no longer need.' }; + } + const usage = await this.licenseGuard.getUsage(ctx.userId, ctx.organizationId).catch(() => null); + const onTrial = usage?.plan === 'trial'; + return { + error: reason, + whatTheUserCanDo: onTrial + ? 'Add a card to continue the trial on the full plan (nothing is charged before the trial ends), or remove a connector.' + : 'Choose a plan with more connectors, or remove a connector.', + upgradeUrl: `${ctx.dashboardBase}${onTrial ? '/start-trial' : '/settings/license'}`, + }; + } + // ── Status ──────────────────────────────────────────────────────────── async status(ctx: SetupContext): Promise { diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index ed6ec476..34b12601 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -19,6 +19,7 @@ import { cardTrialEligible, parsePlanIntent, readCardTrialPrompt, + saveAuthorizationReturn, savePlanIntent, parsePromoCode, savePromoCode, @@ -177,6 +178,31 @@ function LoginForm() { return true; }; + /** + * Cloud only: someone who signed up from "Connect" in an AI client (the + * redirect is the pending authorization) is offered the card trial before + * approving, once. Every way out of /start-trial (skip, a cancelled or a + * completed checkout) returns to this authorization, which the backend + * keeps for 30 minutes. Resolves true when it has navigated. + */ + const offerCardTrialBeforeAuthorization = async ( + u: { id?: string; role?: string } | null | undefined, + token: string, + ): Promise => { + if (!isCloudMode || !returningToAuthorization || !u?.id || u.role !== 'ADMIN') return false; + if (readCardTrialPrompt(u.id) !== null) return false; + try { + const lic = await license.getStatus(token); + if (!cardTrialEligible({ isCloud: true, role: u.role, license: lic })) return false; + } catch { + return false; + } + writeCardTrialPrompt(u.id, 'shown'); + saveAuthorizationReturn(redirectTo); + router.push('/start-trial'); + return true; + }; + // Surface a failure the SSO callback redirected back with. useEffect(() => { if (errorParam) setError(errorParam); @@ -305,6 +331,7 @@ function LoginForm() { login(result.accessToken, result.user); if (isCloudMode && needsLicenseSetup && returningToAuthorization) { await license.activateTrial(result.accessToken).catch(() => undefined); + if (await offerCardTrialBeforeAuthorization(result.user, result.accessToken)) return; goTo(redirectTo); } else if (isCloudMode && needsLicenseSetup) { // Cloud mode: auto-activate trial for verified users @@ -352,7 +379,9 @@ function LoginForm() { 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. + // of connecting an AI client: offer the card trial once, then back + // to approve. + if (await offerCardTrialBeforeAuthorization(verifiedUser, authToken)) return; goTo(redirectTo); } else if (isCloudMode) { // Cloud mode: auto-activate trial diff --git a/packages/frontend/src/app/settings/license/activate/page.tsx b/packages/frontend/src/app/settings/license/activate/page.tsx index 6f616eee..70f4a11a 100644 --- a/packages/frontend/src/app/settings/license/activate/page.tsx +++ b/packages/frontend/src/app/settings/license/activate/page.tsx @@ -9,6 +9,7 @@ import { buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { cn } from '@/lib/utils'; import { sessionStore } from '@/lib/storage'; +import { takeAuthorizationReturn } from '@/lib/card-trial'; const KEY_RE = /^AMCP-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}$/; const PENDING_KEY = 'amcp_pending_license_key'; @@ -45,6 +46,7 @@ function LicenseActivateInner() { const key = rawKey.toUpperCase(); const [phase, setPhase] = useState('loading'); const [message, setMessage] = useState(''); + const [redirectLabel, setRedirectLabel] = useState('Redirecting to settings…'); const ran = useRef(false); useEffect(() => { @@ -85,10 +87,14 @@ function LicenseActivateInner() { sessionStore.remove(PENDING_KEY); setPhase('success'); setMessage(res.message || 'License activated successfully.'); + // Started the card trial while connecting an AI client: finish that + // authorization (it waits 30 minutes) instead of opening settings. + const next = takeAuthorizationReturn('/settings/license'); + setRedirectLabel(next.startsWith('/auth/') ? 'Taking you back to finish connecting your AI client…' : 'Redirecting to settings…'); // A full load rather than a client-side route change: the app shell // (trial banner, licence wall) read the licence before activation and // would keep showing the trial until the next reload. - setTimeout(() => window.location.replace('/settings/license'), 1500); + setTimeout(() => window.location.replace(next), 1500); }) .catch((err: any) => { setPhase('error'); @@ -112,7 +118,7 @@ function LicenseActivateInner() { {phase === 'success' && (

{message}

-

Redirecting to settings…

+

{redirectLabel}

)} diff --git a/packages/frontend/src/app/start-trial/page.tsx b/packages/frontend/src/app/start-trial/page.tsx index 95372849..39d2d8e9 100644 --- a/packages/frontend/src/app/start-trial/page.tsx +++ b/packages/frontend/src/app/start-trial/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { license } from '@/lib/api'; @@ -14,8 +14,10 @@ import { cardTrialEligible, formatTrialEnd, planById, + readAuthorizationReturn, readPlanIntent, readPromoCode, + takeAuthorizationReturn, writeCardTrialPrompt, type PlanSelection, } from '@/lib/card-trial'; @@ -39,6 +41,19 @@ export default function StartTrialPage() { const [ready, setReady] = useState(false); const [selection, setSelection] = useState(DEFAULT_SELECTION); const [promo, setPromo] = useState(null); + // Set when the user came from "Connect" in an AI client: every way out of + // this page goes back to that authorization. + const [authorizationReturn, setAuthorizationReturn] = useState(null); + + /** Leave for the waiting authorization (a backend page) or for `fallback`. */ + const leave = useCallback( + (fallback: string) => { + const target = takeAuthorizationReturn(fallback); + if (target.startsWith('/auth/')) window.location.assign(target); + else router.replace(target); + }, + [router], + ); useEffect(() => { if (isLoading || !deploymentModeLoaded) return; @@ -47,7 +62,7 @@ export default function StartTrialPage() { return; } if (deploymentMode !== 'cloud' || user.role !== 'ADMIN') { - router.replace('/'); + leave('/'); return; } let live = true; @@ -56,21 +71,22 @@ export default function StartTrialPage() { .then((lic) => { if (!live) return; if (!cardTrialEligible({ isCloud: true, role: user.role, license: lic })) { - router.replace('/'); + leave('/'); return; } + setAuthorizationReturn(readAuthorizationReturn()); setTrialEnd(cardTrialDisplayEnd(lic.expiresAt)); setSelection(readPlanIntent() ?? DEFAULT_SELECTION); setPromo(readPromoCode()); setReady(true); }) .catch(() => { - if (live) router.replace('/'); + if (live) leave('/'); }); return () => { live = false; }; - }, [isLoading, deploymentModeLoaded, deploymentMode, token, user, router]); + }, [isLoading, deploymentModeLoaded, deploymentMode, token, user, router, leave]); const handleStart = async () => { if (!user) return; @@ -80,8 +96,9 @@ export default function StartTrialPage() { const handleSkip = () => { if (user) writeCardTrialPrompt(user.id, 'skipped'); - // The dashboard decides what comes next (usually the /welcome wizard). - router.replace('/'); + // Back to the AI client's authorization if one is waiting; otherwise the + // dashboard decides what comes next (usually the /welcome wizard). + leave('/'); }; if (!ready) { @@ -132,6 +149,11 @@ export default function StartTrialPage() { lower connector limit, and you can add a card any time to unlock your full plan.

+ {authorizationReturn && ( +

+ Either way, you go straight back to finish connecting your AI client. +

+ )} diff --git a/packages/frontend/src/lib/card-trial.ts b/packages/frontend/src/lib/card-trial.ts index 14591689..5b8ebcb0 100644 --- a/packages/frontend/src/lib/card-trial.ts +++ b/packages/frontend/src/lib/card-trial.ts @@ -1,4 +1,5 @@ import { storage } from './storage'; +import { safeRedirect } from './safe-redirect'; /** * The card trial on AnythingMCP Cloud: right after sign-up an admin is offered @@ -260,3 +261,64 @@ export function formatTrialEnd( ...(timeZone && { timeZone }), }); } + +// ── Back to an AI client's authorization ──────────────────────────────────── + +/** + * Someone who signs up from "Connect" in Claude is offered the card trial + * before approving the connection. The authorization waits for them (the + * backend keeps it 30 minutes), so the way back is remembered across the + * detour: skipping, a cancelled checkout (Stripe returns to /start-trial) and + * a completed one (the licence site returns to /settings/license/activate) + * all end on the same consent page. + * + * localStorage, not sessionStorage: Stripe can open in another tab on some + * mobile browsers. Only the backend's own /auth/ pages qualify. + */ +const AUTH_RETURN_KEY = 'amcp_card_trial_return'; +export const AUTH_RETURN_TTL_MS = 30 * 60 * 1000; + +export function isAuthorizationPath(path: string | null | undefined): path is string { + return typeof path === 'string' && path.startsWith('/auth/') && safeRedirect(path, '') === path; +} + +export function saveAuthorizationReturn(path: string, now: number = Date.now()): void { + if (!isAuthorizationPath(path)) return; + storage.set(AUTH_RETURN_KEY, JSON.stringify({ path, savedAt: now })); +} + +/** The consent page to go back to, or null when none is waiting (or it expired). */ +export function readAuthorizationReturn(now: number = Date.now()): string | null { + const raw = storage.get(AUTH_RETURN_KEY); + if (!raw) return null; + try { + const data = JSON.parse(raw); + const savedAt = Number(data?.savedAt); + if ( + isAuthorizationPath(data?.path) && + Number.isFinite(savedAt) && + savedAt <= now && + now - savedAt <= AUTH_RETURN_TTL_MS + ) { + return data.path; + } + } catch { + /* fall through */ + } + storage.remove(AUTH_RETURN_KEY); + return null; +} + +export function clearAuthorizationReturn(): void { + storage.remove(AUTH_RETURN_KEY); +} + +/** + * Where to go after the card-trial offer: the waiting authorization if there + * is one (spent here, so it is followed once), otherwise `fallback`. + */ +export function takeAuthorizationReturn(fallback: string): string { + const path = readAuthorizationReturn(); + clearAuthorizationReturn(); + return path ?? fallback; +} diff --git a/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts b/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts new file mode 100644 index 00000000..34bea59f --- /dev/null +++ b/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts @@ -0,0 +1,149 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * Sign-up from "Connect" in Claude: after verifying the email, a new admin on + * a fresh free trial is offered the card trial once, then always returns to + * the pending authorization (/auth/login, served by the backend), whichever + * way they leave the offer: + * - "Continue without payment details" → back to approve; + * - a completed checkout → the licence site returns to + * /settings/license/activate#key=…, which activates and goes back; + * - a cancelled checkout → Stripe returns to /start-trial, skip → back. + * The authorization must never be stranded, which is what an earlier version + * of the trial offer did in this flow. + */ + +const CLOUD_INFO = { deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] }; +const NEUTRAL = { verificationRequired: true, message: 'Check your inbox.' }; +const TRIAL_END = new Date(Date.now() + 7 * 86_400_000).toISOString(); +const KEY = 'AMCP-AB12-CD34-EF56-0789'; + +interface Calls { + paths: string[]; + checkout: unknown[]; + setKey: unknown[]; +} + +async function mockCloud(page: Page, opts: { role?: string; license?: Record } = {}): Promise { + const calls: Calls = { paths: [], checkout: [], setKey: [] }; + const user = { id: 'u-claude', email: 'claude@example.test', name: 'Jane', role: opts.role ?? 'ADMIN', organizationId: 'o1' }; + await page.route(/\/(api|health)\//, async (route) => { + const req = route.request(); + const p = new URL(req.url()).pathname; + calls.paths.push(p); + const json = (body: unknown, status = 200) => route.fulfill({ status, json: body }); + if (p === '/health/server-info') return json(CLOUD_INFO); + if (p === '/api/auth/register') return json(NEUTRAL, 201); + if (p === '/api/auth/login') return json({ accessToken: 't', user: { ...user, emailVerified: false }, needsLicenseSetup: true }); + if (p === '/api/auth/verify-email') return json({ message: 'ok', emailVerified: true }); + if (p === '/api/license/status') { + return json(opts.license ?? { plan: 'trial', status: 'active', expiresAt: TRIAL_END, trialDaysLeft: 7, features: {} }); + } + if (p === '/api/license/checkout-link') { + calls.checkout.push(req.postDataJSON()); + return json({ url: 'https://checkout.example.test/start?intent=i1' }); + } + if (p === '/api/license/key' || p === '/api/license/activate') { + calls.setKey.push(req.postDataJSON()); + return json({ message: 'License activated successfully.' }); + } + if (p.endsWith('/users/me')) return json({ ...user, emailVerified: true }); + return json({}); + }); + // The backend's authorization page and Stripe Checkout, stubbed. + await page.route((url) => url.pathname === '/auth/login', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: '

Authorize AnythingMCP

' }), + ); + await page.route('https://checkout.example.test/**', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: '

Stripe Checkout

' }), + ); + return calls; +} + +async function signUpFromClaude(page: Page) { + await page.goto('/login?mode=register&redirect=%2Fauth%2Flogin'); + await page.locator('#auth-name').fill('Jane'); + await page.locator('#auth-email').fill('claude@example.test'); + await page.locator('#auth-password').fill('Str0ng#Passw0rd'); + await page.locator('#auth-confirm-password').fill('Str0ng#Passw0rd'); + await page.getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Create Account' }).click(); + await expect(page.getByRole('heading', { name: 'Verify Your Email' })).toBeVisible(); + await page.locator('input[autocomplete="one-time-code"]').fill('123456'); + await page.getByRole('button', { name: 'Verify Email' }).click(); +} + +test.describe('card trial when signing up from Claude', () => { + test('is offered once after verifying, and skipping goes back to approve', async ({ page }) => { + const calls = await mockCloud(page); + await signUpFromClaude(page); + + await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); + await expect(page.getByText('you go straight back to finish connecting your AI client')).toBeVisible(); + await page.getByRole('button', { name: 'Try free without payment details' }).click(); + + await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Authorize AnythingMCP' })).toBeVisible(); + expect(calls.checkout).toHaveLength(0); + // The way back is spent: a later visit to /start-trial does not detour again. + expect(await page.evaluate(() => localStorage.getItem('amcp_card_trial_return'))).toBeNull(); + }); + + test('a completed checkout activates the licence and goes back to approve', async ({ page }) => { + const calls = await mockCloud(page); + await signUpFromClaude(page); + await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); + + await page.getByRole('button', { name: 'Start free trial with card' }).click(); + await expect(page.getByRole('heading', { name: 'Stripe Checkout' })).toBeVisible(); + expect(calls.checkout).toEqual([expect.objectContaining({ trial: true })]); + + // The licence site's success page hands the key over in the fragment. + await page.goto(`/settings/license/activate#key=${KEY}`); + await expect(page.getByText('Taking you back to finish connecting your AI client')).toBeVisible(); + await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); + expect(JSON.stringify(calls.setKey)).toContain(KEY); + }); + + test('a cancelled checkout comes back to the offer, and skipping still goes back to approve', async ({ page }) => { + await mockCloud(page); + await signUpFromClaude(page); + await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); + await page.getByRole('button', { name: 'Start free trial with card' }).click(); + await expect(page.getByRole('heading', { name: 'Stripe Checkout' })).toBeVisible(); + + // Stripe's cancel URL for a card trial. + await page.goto('/start-trial'); + await page.getByRole('button', { name: 'Continue without payment details' }).click(); + await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); + }); + + test('a member who cannot subscribe goes straight back to approve', async ({ page }) => { + const calls = await mockCloud(page, { role: 'EDITOR' }); + await signUpFromClaude(page); + await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); + expect(calls.paths).not.toContain('/api/license/checkout-link'); + }); + + test('without a running trial to convert, goes straight back to approve', async ({ page }) => { + await mockCloud(page, { license: { plan: 'cloud_team', status: 'active', expiresAt: TRIAL_END } }); + await signUpFromClaude(page); + await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); + }); + + test('an expired way back is not followed', async ({ page }) => { + // A stale entry (older than the 30 minutes the backend keeps an + // authorization) must not send a later visitor to a dead consent page. + await mockCloud(page); + await page.context().addCookies([{ name: 'amcp_token', value: 't', url: test.info().project.use.baseURL ?? 'http://localhost:3100' }]); + await page.addInitScript(() => { + localStorage.setItem('amcp_token', 't'); + localStorage.setItem('amcp_user', JSON.stringify({ id: 'u-claude', email: 'claude@example.test', role: 'ADMIN', organizationId: 'o1', emailVerified: true })); + localStorage.setItem('amcp_card_trial_return', JSON.stringify({ path: '/auth/login', savedAt: Date.now() - 31 * 60 * 1000 })); + }); + await page.goto('/start-trial'); + await expect(page.getByText('you go straight back to finish connecting your AI client')).toHaveCount(0); + await page.getByRole('button', { name: 'Continue without payment details' }).click(); + await expect(page).not.toHaveURL(/\/auth\/login/); + }); +}); 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 2e3fee8c..4213ff9c 100644 --- a/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts +++ b/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts @@ -83,8 +83,9 @@ 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. + // not on the welcome wizard, which used to strand the connection half way. + // The card-trial offer may come first, but only with a guaranteed way back + // (card-trial-claude-signup.spec.ts); here there is no trial to convert. test('after verifying the email, goes straight back to the authorization page', async ({ page }) => { const calls: string[] = []; await page.route(/\/(api|health)\//, async (route) => {