diff --git a/packages/backend/src/adapters/adapters.controller.spec.ts b/packages/backend/src/adapters/adapters.controller.spec.ts index 021aaea9..62076e22 100644 --- a/packages/backend/src/adapters/adapters.controller.spec.ts +++ b/packages/backend/src/adapters/adapters.controller.spec.ts @@ -9,6 +9,13 @@ function buildController() { }; const licenseGuard = { checkCanCreateConnector: jest.fn().mockResolvedValue(undefined), + getUsage: jest.fn().mockResolvedValue({ + plan: 'trial', + connectors: { current: 0, max: 2, isOver: false }, + mcpServers: { current: 0, max: 2, isOver: false }, + users: { current: 1, max: 1, isOver: false }, + isOverAny: false, + }), }; const mcpServers = { attachToDefaultServer: jest @@ -98,12 +105,28 @@ describe('AdaptersController — starter pack', () => { return built; } - it('serves the pack for the caller workspace', async () => { + it('serves the pack plus the plan connector allowance for the caller workspace', async () => { const { controller, adaptersService } = withPack(); - await expect(controller.starterPack(req('ADMIN'))).resolves.toEqual(pack); + await expect(controller.starterPack(req('ADMIN'))).resolves.toEqual({ + items: pack, + connectors: { current: 0, max: 2, remaining: 2 }, + }); expect((adaptersService as any).starterPack).toHaveBeenCalledWith('org1'); }); + it('reports an uncapped plan as unlimited remaining', async () => { + const { controller, licenseGuard } = withPack(); + licenseGuard.getUsage.mockResolvedValueOnce({ + plan: 'business', + connectors: { current: 3, max: null, isOver: false }, + mcpServers: { current: 1, max: null, isOver: false }, + users: { current: 1, max: 10, isOver: false }, + isOverAny: false, + }); + const res = await controller.starterPack(req('ADMIN')); + expect(res.connectors).toEqual({ current: 3, max: null, remaining: null }); + }); + it('rejects a VIEWER before installing anything', async () => { const { controller, adaptersService } = withPack(); await expect( diff --git a/packages/backend/src/adapters/adapters.controller.ts b/packages/backend/src/adapters/adapters.controller.ts index efa66ad8..6bcfb248 100644 --- a/packages/backend/src/adapters/adapters.controller.ts +++ b/packages/backend/src/adapters/adapters.controller.ts @@ -53,10 +53,29 @@ export class AdaptersController { @ApiOperation({ summary: 'Connectors offered to a new workspace', description: - 'Keyless connectors that install in one click, with whether this workspace already has each one.', + 'Keyless connectors that install in one click, with whether this workspace already has each one, ' + + 'plus the connector allowance of the current plan so the page never preselects more than will fit.', }) - starterPack(@Req() req: any) { - return this.adaptersService.starterPack(req.user.organizationId); + async starterPack(@Req() req: any) { + const [items, usage] = await Promise.all([ + this.adaptersService.starterPack(req.user.organizationId), + // Usage is cloud/licence-specific; on a plan with no cap `max` is null. + this.licenseGuard + .getUsage(req.user.sub, req.user.organizationId) + .catch(() => null), + ]); + const current = usage?.connectors.current ?? 0; + const max = usage?.connectors.max ?? null; + return { + items, + // `remaining` is null when the plan is uncapped; otherwise how many more + // connectors this workspace can add right now (never negative). + connectors: { + current, + max, + remaining: max == null ? null : Math.max(0, max - current), + }, + }; } @Post('starter-pack/install') diff --git a/packages/backend/src/auth/auth.controller.spec.ts b/packages/backend/src/auth/auth.controller.spec.ts index 83f2f9fa..6aaf372c 100644 --- a/packages/backend/src/auth/auth.controller.spec.ts +++ b/packages/backend/src/auth/auth.controller.spec.ts @@ -38,11 +38,18 @@ function makeController({ const sent: string[] = []; const tokens: { userId: string; createdAt: Date; usedAt: Date | null }[] = []; const events: any[] = []; + const invites: any[] = []; const authService = { hashPassword: jest.fn(async (p: string) => `hash:${p}`), comparePassword: jest.fn(async (p: string, h: string) => h === `hash:${p}`), generateToken: jest.fn(() => 'jwt'), + // Test sessions are the literal string `sess:`; anything else is an + // invalid token. + verifyToken: jest.fn((t: string) => { + if (typeof t === 'string' && t.startsWith('sess:')) return { email: t.slice(5) }; + throw new Error('invalid token'); + }), }; const usersService = { findByEmail: jest.fn(async (email: string) => users.find((u) => u.email === email) ?? null), @@ -62,6 +69,11 @@ function makeController({ users.push(u); return u; }), + update: jest.fn(async (id: string, data: any) => { + const u = users.find((x) => x.id === id); + if (u) Object.assign(u, data); + return u; + }), }; const prisma = { organization: { findFirst: jest.fn(async () => ({ id: 'org-first' })) }, @@ -78,6 +90,19 @@ function makeController({ ), }, passwordResetToken: { create: jest.fn(async ({ data }: any) => data) }, + invitationToken: { + findUnique: jest.fn(async ({ where }: any) => invites.find((i) => i.token === where.token) ?? null), + findFirst: jest.fn(async () => null), + create: jest.fn(async ({ data }: any) => { + invites.push({ ...data }); + return data; + }), + update: jest.fn(async ({ where, data }: any) => { + const inv = invites.find((i) => i.token === where.token || i.id === where.id); + if (inv) Object.assign(inv, data); + return inv; + }), + }, productEvent: { create: jest.fn(async ({ data }: any) => { events.push(data); @@ -111,8 +136,14 @@ function makeController({ const organizationsService = { create: jest.fn(async () => ({ id: `org-${users.length + 1}` })), addMember: jest.fn(async () => undefined), + getMembership: jest.fn(async () => null), + switchOrg: jest.fn(async (userId: string, organizationId: string) => { + const u = users.find((x) => x.id === userId); + return { ...(u ?? {}), organizationId }; + }), }; const mcpServersService = { createDefaultForUser: jest.fn(async () => undefined) }; + const rolesService = { setUserRoles: jest.fn(async () => undefined) }; const ssoEnforcement = { isPasswordLoginBlocked: jest.fn(async () => false) }; const controller = new AuthController( @@ -129,14 +160,25 @@ function makeController({ getLatestInactiveLicense: jest.fn(async () => orgEndedLicense), } as any, // licenseService {} as any, // securityEvents - {} as any, // rolesService + rolesService as any, // rolesService {} as any, // recoveryCodes ssoEnforcement as any, // The real service, so the test sees what would actually be stored. new ProductEventService(prisma as any), { assertSeatAvailable: jest.fn(async () => undefined), getState: jest.fn(async () => ({ trialAvailable: true })) } as any, // edition ); - return { controller, users, sent, events, authService, usersService, emailService, prisma }; + return { + controller, + users, + sent, + events, + invites, + authService, + usersService, + emailService, + organizationsService, + prisma, + }; } const flush = () => new Promise((resolve) => setImmediate(resolve)); @@ -350,3 +392,120 @@ describe('AuthController.login — cloud licence setup', () => { expect((await login(controller)).needsLicenseSetup).toBe(true); }); }); + +/** + * An invitation link is not proof that whoever holds it controls the invited + * address. Accepting an invite for an address that ALREADY has an account must + * require that account's password (or a live session for it) — otherwise any + * admin (i.e. any signed-up user) could invite an arbitrary address and take + * the account over by accepting the invite themselves. + */ +describe('AuthController — accept-invite ownership check', () => { + const makeInvite = (over: Partial = {}) => ({ + id: 'inv1', + token: 'invite-token', + email: 'taken@example.com', + role: 'EDITOR', + mcpRoleId: null, + mcpRoleIds: [], + organizationId: 'org-new', + expiresAt: new Date(Date.now() + 3600_000), + usedAt: null, + ...over, + }); + + it('refuses to attach an existing account without the right password', async () => { + const a = makeController({ mode: 'cloud', accounts: [EXISTING] }); + a.invites.push(makeInvite()); + await expect( + a.controller.acceptInvite({ headers: {} }, { token: 'invite-token', password: 'Wrong#Pass9' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(a.organizationsService.addMember).not.toHaveBeenCalled(); + }); + + it('refuses with no password at all', async () => { + const a = makeController({ mode: 'cloud', accounts: [EXISTING] }); + a.invites.push(makeInvite()); + await expect( + a.controller.acceptInvite({ headers: {} }, { token: 'invite-token' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(a.organizationsService.addMember).not.toHaveBeenCalled(); + }); + + it('accepts an existing account with its correct password', async () => { + const a = makeController({ mode: 'cloud', accounts: [EXISTING] }); + a.invites.push(makeInvite()); + const res = await a.controller.acceptInvite( + { headers: {} }, + { token: 'invite-token', password: 'Correct#Horse1' }, + ); + expect(res.accessToken).toBe('jwt'); + expect(a.organizationsService.addMember).toHaveBeenCalledWith( + 'u-existing', + 'org-new', + 'EDITOR', + ); + }); + + it('accepts an existing account with a live session for the same address, no password', async () => { + const a = makeController({ mode: 'cloud', accounts: [EXISTING] }); + a.invites.push(makeInvite()); + const res = await a.controller.acceptInvite( + { headers: { authorization: 'Bearer sess:taken@example.com' } }, + { token: 'invite-token' }, + ); + expect(res.accessToken).toBe('jwt'); + expect(a.organizationsService.addMember).toHaveBeenCalled(); + }); + + it('ignores a session that belongs to a different address', async () => { + const a = makeController({ mode: 'cloud', accounts: [EXISTING] }); + a.invites.push(makeInvite()); + await expect( + a.controller.acceptInvite( + { headers: { authorization: 'Bearer sess:attacker@example.com' } }, + { token: 'invite-token' }, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(a.organizationsService.addMember).not.toHaveBeenCalled(); + }); + + it('tells an SSO-only existing account to sign in first (password cannot prove it)', async () => { + const sso: Account = { ...EXISTING, passwordHash: null }; + const a = makeController({ mode: 'cloud', accounts: [sso] }); + a.invites.push(makeInvite()); + await expect( + a.controller.acceptInvite({ headers: {} }, { token: 'invite-token', password: 'anything' }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('creates a brand-new account from an invite (no ownership check needed)', async () => { + const a = makeController({ mode: 'cloud', accounts: [] }); + a.invites.push(makeInvite({ email: 'fresh@example.com' })); + const res = await a.controller.acceptInvite( + { headers: {} }, + { token: 'invite-token', password: 'Brand#New123', name: 'Fresh' }, + ); + expect(res.accessToken).toBe('jwt'); + expect(a.users.map((u) => u.email)).toContain('fresh@example.com'); + }); + + it('rejects a weak password for a brand-new account', async () => { + const a = makeController({ mode: 'cloud', accounts: [] }); + a.invites.push(makeInvite({ email: 'fresh@example.com' })); + await expect( + a.controller.acceptInvite( + { headers: {} }, + { token: 'invite-token', password: 'weak', name: 'Fresh' }, + ), + ).rejects.toBeTruthy(); + }); + + it('verifyInvite reports whether the address already exists', async () => { + const a = makeController({ mode: 'cloud', accounts: [EXISTING] }); + a.invites.push(makeInvite()); + a.invites.push(makeInvite({ id: 'inv2', token: 't2', email: 'fresh@example.com' })); + expect(await a.controller.verifyInvite('invite-token')).toMatchObject({ exists: true }); + expect(await a.controller.verifyInvite('t2')).toMatchObject({ exists: false }); + }); +}); diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 84f4436a..5b83dfea 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -189,15 +189,19 @@ class AcceptInviteDto { @IsString() token: string; - @ApiProperty({ description: 'Account password.', format: 'password' }) + // Optional: a brand-new account needs a password to be created with; an + // existing account proves control with its *current* password (or a live + // session), so strength is enforced per-branch in the handler rather than + // here — an old, still-valid password must not be rejected by today's rules. + @ApiPropertyOptional({ description: 'Password (new account) or the existing account password to prove ownership.', format: 'password' }) + @IsOptional() @IsString() - @MinLength(8) - @Matches(PASSWORD_REGEX, { message: PASSWORD_MESSAGE }) - password: string; + password?: string; - @ApiProperty({ description: 'Display name for the new account.' }) + @ApiPropertyOptional({ description: 'Display name for a new account.' }) + @IsOptional() @IsString() - name: string; + name?: string; } @ApiTags('Auth') @@ -239,6 +243,24 @@ export class AuthController { ); } + /** + * The verified dashboard-session email on a request, or null. Used to let an + * already-signed-in owner accept an invitation to one of their other + * addresses without re-typing a password. Never throws: a missing, malformed + * or expired token is simply "no session". + */ + private sessionEmailFromRequest(req: any): string | null { + const header: string | undefined = req?.headers?.authorization; + if (!header?.startsWith('Bearer ')) return null; + try { + const payload = this.authService.verifyToken(header.slice(7).trim()); + const email = (payload as any)?.email; + return typeof email === 'string' ? email.toLowerCase() : null; + } catch { + return null; + } + } + private async createAndSendVerificationCode( userId: string, email: string, @@ -839,16 +861,26 @@ export class AuthController { if (invite.usedAt) throw new BadRequestException('This invitation has already been used'); if (invite.expiresAt < new Date()) throw new BadRequestException('This invitation has expired'); + // Tell the page whether this address already has an account, so it can ask + // the existing owner to prove control (sign in / existing password) rather + // than offer to "create an account" and silently ignore what they type. + // No enumeration risk: the token is 32 unguessable bytes bound to the one + // address the admin chose, so whoever holds it is already the invitee. + const existing = await this.usersService.findByEmail(invite.email); return { email: invite.email, role: invite.role, valid: true, + exists: !!existing, + // A password can only be verified for a local account; SSO-only accounts + // (no password hash) must sign in through their provider. + ssoOnly: !!existing && !existing.passwordHash, }; } @Post('accept-invite') @ApiOperation({ summary: 'Accept an invitation and create account' }) - async acceptInvite(@Body() dto: AcceptInviteDto) { + async acceptInvite(@Req() req: any, @Body() dto: AcceptInviteDto) { const invite = await this.prisma.invitationToken.findUnique({ where: { token: dto.token }, }); @@ -862,6 +894,33 @@ export class AuthController { let user: any; if (existing) { + // SECURITY: an invitation link is not proof that the holder controls the + // invited address. Before attaching an *existing* account to the new org + // (and handing back a session for it), the caller must prove they are + // that account's owner — either a live session for the same address, or + // its current password. Otherwise anyone who can create an invite (every + // workspace admin, i.e. every signed-up user) could take over an + // arbitrary account by inviting it and accepting the invite themselves. + const sessionEmail = this.sessionEmailFromRequest(req); + const provenBySession = + !!sessionEmail && sessionEmail === invite.email.toLowerCase(); + if (!provenBySession) { + if (!existing.passwordHash) { + throw new UnauthorizedException( + 'This email already has an account that signs in with single sign-on. ' + + 'Sign in first, then open this invitation again to join the workspace.', + ); + } + const ok = + !!dto.password && + (await this.authService.comparePassword(dto.password, existing.passwordHash)); + if (!ok) { + throw new UnauthorizedException( + 'Enter the current password for this account to accept the invitation.', + ); + } + } + // Existing user — add to the new organization (multi-org) const alreadyMember = await this.organizationsService.getMembership(existing.id, invite.organizationId); if (alreadyMember) { @@ -878,7 +937,15 @@ export class AuthController { // Switch their active org to the newly joined one user = await this.organizationsService.switchOrg(existing.id, invite.organizationId); } else { - // New user — create account and join the organization + // New user — create account and join the organization. A password that + // meets today's rules and a name are required here (the DTO allows them + // to be absent for the existing-account path above). + if (!dto.password || !PASSWORD_REGEX.test(dto.password)) { + throw new BadRequestException(PASSWORD_MESSAGE); + } + if (!dto.name || !dto.name.trim()) { + throw new BadRequestException('A display name is required.'); + } await this.edition.assertSeatAvailable(); const passwordHash = await this.authService.hashPassword(dto.password); user = await this.usersService.create({ diff --git a/packages/backend/src/auth/login.controller.ts b/packages/backend/src/auth/login.controller.ts index 4af482b2..c5d70ed9 100644 --- a/packages/backend/src/auth/login.controller.ts +++ b/packages/backend/src/auth/login.controller.ts @@ -787,6 +787,20 @@ export class LoginController { const submitLabel = consent ? 'Sign In & Authorize' : 'Sign In'; + // Someone arriving here from an AI client (e.g. the Claude directory) + // 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). + const preAuthLinks = sessionUser + ? '' + : ` +

Forgot your password?

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

New to AnythingMCP? Create an account

` + : ''); + // Signed in to the dashboard already: approve as that account, or switch. // `switch=1` keeps the pending OAuth session (it lives in a cookie) and // shows the password form instead. @@ -803,7 +817,8 @@ export class LoginController { - ${denyButton}`; + ${denyButton} + ${preAuthLinks}`; return ` diff --git a/packages/frontend/src/app/accept-invite/page.tsx b/packages/frontend/src/app/accept-invite/page.tsx index 69e6c43c..073984a8 100644 --- a/packages/frontend/src/app/accept-invite/page.tsx +++ b/packages/frontend/src/app/accept-invite/page.tsx @@ -30,11 +30,13 @@ const inputClass = function AcceptInviteContent() { const searchParams = useSearchParams(); const router = useRouter(); - const { login } = useAuth(); + const { login, token: sessionToken, user: sessionUser } = useAuth(); const token = searchParams.get('token') || ''; const [email, setEmail] = useState(''); const [role, setRole] = useState(''); + const [exists, setExists] = useState(false); + const [ssoOnly, setSsoOnly] = useState(false); const [valid, setValid] = useState(null); const [error, setError] = useState(''); const [name, setName] = useState(''); @@ -53,6 +55,8 @@ function AcceptInviteContent() { .then((data) => { setEmail(data.email); setRole(data.role); + setExists(!!data.exists); + setSsoOnly(!!data.ssoOnly); setValid(true); }) .catch((err) => { @@ -61,16 +65,19 @@ function AcceptInviteContent() { }); }, [token]); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (password !== confirmPassword) { - setError('Passwords do not match'); - return; - } + // True when the person viewing this page is already signed in as exactly the + // invited address: they can accept in one click, no password needed. + const signedInAsInvitee = + !!sessionUser && !!email && sessionUser.email?.toLowerCase() === email.toLowerCase(); + + const accept = async (payload: { password?: string; name?: string }) => { setSubmitting(true); setError(''); try { - const result = await auth.acceptInvite({ token, password, name }); + const result = await auth.acceptInvite( + { token, ...payload }, + signedInAsInvitee ? sessionToken ?? undefined : undefined, + ); login(result.accessToken, result.user); router.push('/'); } catch (err: any) { @@ -80,6 +87,22 @@ function AcceptInviteContent() { } }; + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + await accept({ password, name }); + }; + + const handleJoinExisting = async (e: React.FormEvent) => { + e.preventDefault(); + // When signed in as the invitee, no password is sent — the session proves + // ownership server-side. + await accept(signedInAsInvitee ? {} : { password }); + }; + return (
{valid === false ? ( @@ -103,7 +126,9 @@ function AcceptInviteContent() {

Accept Invitation

- Create your AnythingMCP account + {exists + ? 'Join this workspace with your existing account' + : 'Create your account to join this workspace'}

@@ -112,74 +137,136 @@ function AcceptInviteContent() { )} {valid === true && ( -
-
+ <> +

- You've been invited as {role} for {email} + You've been invited as {role} for {email}

-
- - setName(e.target.value)} - required - className={inputClass} - placeholder="Full name" - /> -
+ {/* SSO-only account that the viewer is not signed into: they must + authenticate with their provider before joining. */} + {exists && ssoOnly && !signedInAsInvitee ? ( +
+

+ This email already has an account that signs in with single sign-on. Sign in + first, then open this invitation link again to join the workspace. +

+ + + +
+ ) : exists ? ( + // Existing local account: prove ownership (or one click when + // already signed in as this address). + + {!signedInAsInvitee && ( +
+ + setPassword(e.target.value)} + required + autoFocus + placeholder="Enter your existing password" + className={inputClass} + /> +

+ Confirm it's you before we add this account to the workspace. +

+
+ )} + {signedInAsInvitee && ( +

+ You're signed in as {email}. Accept to join this + workspace. +

+ )} + {error &&

{error}

} + + + ) : ( + // Brand-new account. +
+
+ + setName(e.target.value)} + required + className={inputClass} + placeholder="Full name" + /> +
-
- - -
+
+ + +
-
- - setPassword(e.target.value)} - required - minLength={8} - placeholder="Min. 8 characters" - className={inputClass} - /> -
+
+ + setPassword(e.target.value)} + required + minLength={8} + placeholder="Min. 8 characters" + className={inputClass} + /> +
-
- - setConfirmPassword(e.target.value)} - required - minLength={8} - placeholder="Repeat password" - className={inputClass} - /> -
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + placeholder="Repeat password" + className={inputClass} + /> +
- {error && ( -

{error}

- )} + {error &&

{error}

} - -
+ + + )} + )} )} diff --git a/packages/frontend/src/app/connectors/new/page.tsx b/packages/frontend/src/app/connectors/new/page.tsx index b606e988..9ee07187 100644 --- a/packages/frontend/src/app/connectors/new/page.tsx +++ b/packages/frontend/src/app/connectors/new/page.tsx @@ -10,6 +10,7 @@ import { Card } from '@/components/ui/card'; import { McpAssignModal } from '@/components/mcp-assign-modal'; import { AppSelect } from '@/components/ui/select'; import { HeadersEditor, headerRowsToObject, type HeaderRow } from '@/components/headers-editor'; +import { isTrialLimitMessage, TrialLimitNotice } from '@/lib/trial-limit'; import { cn } from '@/lib/utils'; const DEFAULT_LOGIN_BODY = '{\n "username": "${username}",\n "password": "${password}"\n}'; @@ -291,9 +292,13 @@ export default function NewConnectorPage() { Configure {CONNECTOR_TYPES.find((t) => t.id === selectedType)?.name} - {error && ( + {error && isTrialLimitMessage(error) ? ( +
+ +
+ ) : error ? (
{error}
- )} + ) : null} {testResult && (
= { de: 'Germany', @@ -456,7 +457,9 @@ function AdapterStoreContent() { } >
- {msg && ( + {msg && isTrialLimitMessage(msg) ? ( + + ) : msg ? (
- )} + ) : null} {/* Search + Category Filters */}
diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index baa371c3..b81e5548 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -462,12 +462,21 @@ function LoginForm() {

- {isFirstUserFlag && !isCloudMode && ( + {/* Self-hosted does not enforce email verification server-side, so a + verify step with no way past it (no SMTP configured) would be a + dead end. Always let a self-hosted user skip: the first user goes + on to set up the licence, everyone else straight into the app. + Cloud still requires verification, so no skip there. */} + {!isCloudMode && (
- {createError && ( + {createError && isTrialLimitMessage(createError) ? ( + + ) : createError ? (
{createError}
- )} + ) : null}
diff --git a/packages/frontend/src/app/welcome/page.tsx b/packages/frontend/src/app/welcome/page.tsx index 797e0215..e8bff630 100644 --- a/packages/frontend/src/app/welcome/page.tsx +++ b/packages/frontend/src/app/welcome/page.tsx @@ -114,7 +114,9 @@ export default function WelcomePage() { click and put on the user's MCP server. It replaces the old single-connector demo, and offers the same live "Try it" call for each connector once it is added. */} - {token && } + {/* Viewers can't add connectors (the install endpoint rejects them), + so don't offer the pack only to fail. */} + {token && user.role !== 'VIEWER' && } {/* Two big paths — marketplace vs custom */}
diff --git a/packages/frontend/src/components/starter-pack.tsx b/packages/frontend/src/components/starter-pack.tsx index 19db4ff3..270f88ee 100644 --- a/packages/frontend/src/components/starter-pack.tsx +++ b/packages/frontend/src/components/starter-pack.tsx @@ -21,6 +21,9 @@ import { ConnectorLogo } from '@/components/connector-logo'; export function StarterPack({ token }: { token: string }) { const [items, setItems] = useState(null); const [selected, setSelected] = useState>(new Set()); + // How many more connectors the plan allows right now; null means uncapped. + const [remaining, setRemaining] = useState(null); + const [maxConnectors, setMaxConnectors] = useState(null); const [installing, setInstalling] = useState(false); const [error, setError] = useState(''); const [outcome, setOutcome] = useState<{ @@ -33,9 +36,16 @@ export function StarterPack({ token }: { token: string }) { adapters .starterPack(token) .then((res) => { - const list = Array.isArray(res) ? res : []; + const list = res?.items ?? []; + const rem = res?.connectors?.remaining ?? null; setItems(list); - setSelected(new Set(list.filter((i) => i.preselected && !i.installed).map((i) => i.slug))); + setRemaining(rem); + setMaxConnectors(res?.connectors?.max ?? null); + // Never preselect more than the plan can fit, so the one-click install + // can't fail its last item on the trial's 2-connector cap. + const preselectable = list.filter((i) => i.preselected && !i.installed).map((i) => i.slug); + const capped = rem == null ? preselectable : preselectable.slice(0, Math.max(0, rem)); + setSelected(new Set(capped)); if (list.length > 0 && !viewed.current) { viewed.current = true; productEvents.track('starter_pack_viewed', token); @@ -50,12 +60,19 @@ export function StarterPack({ token }: { token: string }) { const offered = items.filter((i) => !i.installed); const allInstalled = offered.length === 0; + const atCap = remaining != null && selected.size >= remaining; const toggle = (slug: string) => { setSelected((prev) => { const next = new Set(prev); - if (next.has(slug)) next.delete(slug); - else next.add(slug); + if (next.has(slug)) { + next.delete(slug); + } else { + // Respect the plan's connector allowance: don't let the user pick more + // than will install. + if (remaining != null && next.size >= remaining) return prev; + next.add(slug); + } return next; }); }; @@ -86,10 +103,24 @@ export function StarterPack({ token }: { token: string }) {

Start with connectors that need no keys

-

+

We add them to your MCP server, so your AI can use them as soon as you connect it. Untick what you don't need; you can remove any of them later.

+ {maxConnectors != null && !outcome && !allInstalled && ( +

+ Your current plan includes {maxConnectors}{' '} + connector{maxConnectors === 1 ? '' : 's'}. Pick the ones you want now — you can add your own + next.{' '} + + Add a card for more + + . +

+ )} {outcome ? ( @@ -114,14 +145,20 @@ export function StarterPack({ token }: { token: string }) { ? 'cursor-default border-[var(--border)] bg-[var(--surface-2)] opacity-80' : checked ? 'cursor-pointer border-[var(--brand)] bg-[var(--brand-tint)]' - : 'cursor-pointer border-[var(--border)] bg-[var(--surface)] hover:border-[var(--border-strong)]') + : atCap + ? 'cursor-not-allowed border-[var(--border)] bg-[var(--surface)] opacity-50' + : 'cursor-pointer border-[var(--border)] bg-[var(--surface)] hover:border-[var(--border-strong)]') } > toggle(item.slug)} aria-describedby={`starter-${item.slug}-pitch`} /> @@ -162,9 +199,14 @@ export function StarterPack({ token }: { token: string }) {

- {selected.size === 0 + {(selected.size === 0 ? 'Nothing selected.' - : `${selected.size} of ${offered.length} selected.`} + : `${selected.size} of ${offered.length} selected.`) + + (remaining != null + ? ` ${remaining - selected.size} connector slot${ + remaining - selected.size === 1 ? '' : 's' + } left on your plan.` + : '')}