Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
28 changes: 28 additions & 0 deletions packages/backend/src/adapters/connector-setup.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
30 changes: 29 additions & 1 deletion packages/backend/src/adapters/connector-setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<AdaptersService['importAdapter']>>;
Expand Down Expand Up @@ -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<Record<string, unknown>> {
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<SetupCallResult> {
Expand Down
31 changes: 30 additions & 1 deletion packages/frontend/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
cardTrialEligible,
parsePlanIntent,
readCardTrialPrompt,
saveAuthorizationReturn,
savePlanIntent,
parsePromoCode,
savePromoCode,
Expand Down Expand Up @@ -177,6 +178,31 @@
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<boolean> => {
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);
Expand Down Expand Up @@ -305,6 +331,7 @@
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
Expand Down Expand Up @@ -352,7 +379,9 @@

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
Expand All @@ -361,7 +390,7 @@
if (offerCardTrial(storedUser, trialResult)) return;
setTrialDaysLeft(trialResult.trialDaysLeft);
setSetupStep('trial-activated');
} catch (trialErr: any) {

Check warning on line 393 in packages/frontend/src/app/login/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, build)

'trialErr' is defined but never used
// Trial may already exist (e.g. returning user) — go to dashboard
goTo(redirectTo);
}
Expand Down
10 changes: 8 additions & 2 deletions packages/frontend/src/app/settings/license/activate/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,6 +46,7 @@ function LicenseActivateInner() {
const key = rawKey.toUpperCase();
const [phase, setPhase] = useState<Phase>('loading');
const [message, setMessage] = useState('');
const [redirectLabel, setRedirectLabel] = useState('Redirecting to settings…');
const ran = useRef(false);

useEffect(() => {
Expand Down Expand Up @@ -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');
Expand All @@ -112,7 +118,7 @@ function LicenseActivateInner() {
{phase === 'success' && (
<div className="text-center">
<p className="text-[var(--ok)] font-medium mb-2">{message}</p>
<p className="text-[var(--text-3)] text-xs">Redirecting to settings…</p>
<p className="text-[var(--text-3)] text-xs">{redirectLabel}</p>
</div>
)}

Expand Down
36 changes: 29 additions & 7 deletions packages/frontend/src/app/start-trial/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,8 +14,10 @@ import {
cardTrialEligible,
formatTrialEnd,
planById,
readAuthorizationReturn,
readPlanIntent,
readPromoCode,
takeAuthorizationReturn,
writeCardTrialPrompt,
type PlanSelection,
} from '@/lib/card-trial';
Expand All @@ -39,6 +41,19 @@ export default function StartTrialPage() {
const [ready, setReady] = useState(false);
const [selection, setSelection] = useState<PlanSelection>(DEFAULT_SELECTION);
const [promo, setPromo] = useState<string | null>(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<string | null>(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;
Expand All @@ -47,7 +62,7 @@ export default function StartTrialPage() {
return;
}
if (deploymentMode !== 'cloud' || user.role !== 'ADMIN') {
router.replace('/');
leave('/');
return;
}
let live = true;
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -132,6 +149,11 @@ export default function StartTrialPage() {
<strong className="text-[var(--text)]">lower connector limit</strong>, and you can add a card
any time to unlock your full plan.
</p>
{authorizationReturn && (
<p className="mt-3 text-sm text-[var(--text-2)] max-w-xl mx-auto">
Either way, you go straight back to finish connecting your AI client.
</p>
)}
</div>

<Card className="p-5 sm:p-6">
Expand Down
62 changes: 62 additions & 0 deletions packages/frontend/src/lib/card-trial.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
}
Loading
Loading