From 14a3e044df6729b8656b522026f4de4d20c892ce Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 1 Oct 2026 12:41:12 +0200 Subject: [PATCH 1/3] Secure invite acceptance, and make the trial's connector limit visible instead of a dead end Security (P0): - accept-invite no longer attaches an EXISTING account (and hands back its session) on the strength of the invite link alone. The invitee must prove ownership with a live session for that address or its current password; SSO-only accounts are told to sign in first. Any admin could otherwise take over an arbitrary account by inviting it and accepting the invite themselves. verifyInvite now reports whether the address already exists so the page can ask to sign in rather than silently ignore a typed password. - The MCP OAuth login page now offers 'Create an account' (cloud) and 'Forgot your password?' links, so someone arriving from an AI client without an account is not stuck. Trial activation (P1): - The starter pack preselected 3 connectors while the trial allows 2, so the one-click install always failed its last item and the two keyless demos ate the slots meant for the user's own connector. The starter-pack endpoint now returns the plan's connector allowance; the page preselects at most what fits, shows the cap and how many slots remain, and disables extra picks. - A trial-limit error on connector/MCP-server create now renders an actionable notice (add a card / remove a connector) instead of a raw red toast. - /start-trial states the no-card trial is limited to 2 connectors / 2 servers. - /welcome no longer offers the starter pack to viewers (who can't install). Tests: new accept-invite ownership suite, starter-pack cap unit + e2e; full backend suite and the starter-pack/card-trial e2e suites pass. --- .../src/adapters/adapters.controller.spec.ts | 27 ++- .../src/adapters/adapters.controller.ts | 25 +- .../backend/src/auth/auth.controller.spec.ts | 163 ++++++++++++- packages/backend/src/auth/auth.controller.ts | 83 ++++++- packages/backend/src/auth/login.controller.ts | 17 +- .../frontend/src/app/accept-invite/page.tsx | 223 ++++++++++++------ .../frontend/src/app/connectors/new/page.tsx | 9 +- .../src/app/connectors/store/page.tsx | 7 +- packages/frontend/src/app/mcp-server/page.tsx | 7 +- .../frontend/src/app/start-trial/page.tsx | 4 +- packages/frontend/src/app/welcome/page.tsx | 4 +- .../frontend/src/components/starter-pack.tsx | 60 ++++- packages/frontend/src/lib/api.ts | 18 +- packages/frontend/src/lib/trial-limit.tsx | 43 ++++ .../frontend/tests/e2e/starter-pack.spec.ts | 31 ++- 15 files changed, 615 insertions(+), 106 deletions(-) create mode 100644 packages/frontend/src/lib/trial-limit.tsx 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/mcp-server/page.tsx b/packages/frontend/src/app/mcp-server/page.tsx index 5b776a59..9d51e625 100644 --- a/packages/frontend/src/app/mcp-server/page.tsx +++ b/packages/frontend/src/app/mcp-server/page.tsx @@ -9,6 +9,7 @@ import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { StatusPill } from '@/components/ui/badge'; import { AppSelect } from '@/components/ui/select'; +import { isTrialLimitMessage, TrialLimitNotice } from '@/lib/trial-limit'; export default function McpServerListPage() { const { token } = useAuth(); @@ -103,11 +104,13 @@ export default function McpServerListPage() { className="w-full rounded-[9px] border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--brand)]" />
- {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.` + : '')}

From 671f62ea8e5e28255245b7b82459fd9d33d3304a Mon Sep 17 00:00:00 2001 From: Matteo Date: Thu, 1 Oct 2026 12:54:45 +0200 Subject: [PATCH 3/3] Self-hosted: always allow skipping email verification (no dead end without SMTP) Self-hosted does not enforce email verification server-side, but the verify step only offered 'Skip for now' to the first user, so a later user (or a returning one) with no SMTP configured had no way past it. Any self-hosted user can now skip; the first user still goes on to licence setup, everyone else into the app. Cloud is unchanged (verification still required). --- packages/frontend/src/app/login/page.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 && (