diff --git a/packages/backend/src/adapters/intl/etsy.json b/packages/backend/src/adapters/intl/etsy.json index 27b7dcde..9178ecf8 100644 --- a/packages/backend/src/adapters/intl/etsy.json +++ b/packages/backend/src/adapters/intl/etsy.json @@ -48,13 +48,14 @@ }, { "name": "etsy_get_user_shops", - "description": "List shops owned by the user. Returns shop_id, shop_name, currency_code, languages, login_name, last_updated_tsz, listing_active_count.", + "description": "List shops owned by a user. Returns shop_id, shop_name, currency_code, languages, login_name, last_updated_tsz, listing_active_count. For the connected account, take user_id from etsy_get_authenticated_user first.", "parameters": { "type": "object", "properties": { "user_id": { "type": "integer", - "description": "User ID." + "description": "Etsy user ID (a positive number): use the user_id returned by etsy_get_authenticated_user.", + "minimum": 1 } }, "required": [ @@ -88,7 +89,7 @@ }, { "name": "etsy_get_shop_listings_active", - "description": "List active listings in a shop.", + "description": "Public list of a shop's active listings, as any visitor sees them. Etsy caps how far this public view can page (large offsets are refused); to go through all of your own shop's listings, or drafts and inactive ones, use etsy_get_listings_by_shop.", "parameters": { "type": "object", "properties": { @@ -98,11 +99,14 @@ }, "limit": { "type": "integer", - "description": "Per page (max 100)." + "description": "Per page, 1 to 100 (default 25).", + "minimum": 1, + "maximum": 100 }, "offset": { "type": "integer", - "description": "Pagination offset." + "description": "Pagination offset (0 = first page). Keep it small: Etsy refuses large offsets on this public endpoint.", + "minimum": 0 }, "sort_on": { "type": "string", @@ -133,6 +137,80 @@ } } }, + { + "name": "etsy_get_listings_by_shop", + "description": "Listings of your own shop by state (active, inactive, sold_out, draft, removed, expired), as the shop owner sees them. Authenticated, so it pages through the whole catalogue, and it can include Inventory and Shipping. Use it rather than etsy_get_shop_listings_active for your own shop.", + "parameters": { + "type": "object", + "properties": { + "shop_id": { + "type": "integer", + "minimum": 1, + "description": "Shop ID (from etsy_get_user_shops)." + }, + "state": { + "type": "string", + "enum": [ + "active", + "inactive", + "sold_out", + "draft", + "removed", + "expired" + ], + "description": "Listing state. Default: active." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Per page, 1 to 100 (default 25)." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Pagination offset (0 = first page)." + }, + "sort_on": { + "type": "string", + "enum": [ + "created", + "price", + "updated", + "score" + ], + "description": "Sort field." + }, + "sort_order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "Sort direction." + }, + "includes": { + "type": "string", + "description": "Optional extra data, comma-separated, any of: Shipping, Images, Shop, User, Translations, Inventory, Videos, Personalization, BuyerPrice." + } + }, + "required": [ + "shop_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/shops/{shop_id}/listings", + "queryParams": { + "state": "$state", + "limit": "$limit", + "offset": "$offset", + "sort_on": "$sort_on", + "sort_order": "$sort_order", + "includes": "$includes" + } + } + }, { "name": "etsy_get_listing", "description": "Fetch one listing by listing_id with full details.", @@ -145,7 +223,7 @@ }, "includes": { "type": "string", - "description": "Comma-separated: Shipping, Images, Shop, User, Translations, Inventory, Videos." + "description": "Optional extra data, comma-separated, any of: Images, Shop, User, Translations, Videos, Personalization, BuyerPrice. Etsy rejects anything else: inventory (SKUs, quantities, prices per variation) is not available here, and shipping comes with the shop's shipping profiles, not with the listing." } }, "required": [ diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index cec34d5d..968ac148 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -92,6 +92,11 @@ if (useOAuth) { // always mounts cookie-parser (see the regression test in // main-cookie-parser.spec.ts), so the check is skipped only then. skipCookieParserCheck: Sentry.isInitialized(), + // How long a pending authorization (an AI client's "Connect" click) + // waits for the user. The default 10 minutes is too short for someone + // without an account: from the login page they create one, verify their + // email and come back to approve. 30 minutes covers that comfortably. + oauthSessionExpiresIn: 30 * 60 * 1000, }), ); } diff --git a/packages/backend/src/auth/login.controller.spec.ts b/packages/backend/src/auth/login.controller.spec.ts index 99697061..2b9e2b1d 100644 --- a/packages/backend/src/auth/login.controller.spec.ts +++ b/packages/backend/src/auth/login.controller.spec.ts @@ -170,6 +170,39 @@ describe('LoginController', () => { expect(res._sent).not.toContain(''); }); + it('offers sign-up on cloud, returning to this page after verification', async () => { + const cloud = new LoginController( + authService as unknown as AuthService, + prisma as unknown as PrismaService, + config as unknown as ConfigService, + store as unknown as PrismaOAuthStore, + sso as unknown as SsoService, + { mode: 'cloud', isCloud: () => true, isSelfHosted: () => false } as any, + grants as any, + ); + const res = makeRes(); + await cloud.showLoginPage( + undefined as unknown as string, + undefined as unknown as string, + makeReq({ cookies: {} }), + res, + ); + expect(res._sent).toContain('href="/login?mode=register&redirect=%2Fauth%2Flogin"'); + expect(res._sent).toContain('href="/forgot-password"'); + }); + + it('offers no sign-up on self-hosted (registration is invite-based there)', async () => { + const res = makeRes(); + await controller.showLoginPage( + undefined as unknown as string, + undefined as unknown as string, + makeReq({ cookies: {} }), + res, + ); + expect(res._sent).not.toContain('mode=register'); + expect(res._sent).toContain('href="/forgot-password"'); + }); + it('falls back to a generic form (no consent block) without a session', async () => { const res = makeRes(); await controller.showLoginPage( diff --git a/packages/backend/src/auth/login.controller.ts b/packages/backend/src/auth/login.controller.ts index c5d70ed9..3e54838c 100644 --- a/packages/backend/src/auth/login.controller.ts +++ b/packages/backend/src/auth/login.controller.ts @@ -791,14 +791,18 @@ export class LoginController { // without an account would otherwise hit a dead end: the only way off this // page was "Use a different account". Give them the way in. The links point // at the dashboard app on the same origin; sign-up is cloud-only (on - // self-hosted, registration is closed or invite-based). + // self-hosted, registration is closed or invite-based). Sign-up carries + // `redirect=/auth/login`, so after verifying their email the new user + // lands back here, still inside the pending authorization (its cookie + // outlives the detour), and approves with one click instead of starting + // over from their AI client. const preAuthLinks = sessionUser ? '' : `
` + (this.deployment.isCloud() ? ` -New to AnythingMCP? Create an account
` +New to AnythingMCP? Create an account
` : ''); // Signed in to the dashboard already: approve as that account, or switch. diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index bbc4d038..1f204db1 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -74,12 +74,36 @@ function LoginForm() { const [registrationEnabled, setRegistrationEnabled] = useState(false); const [isCloudMode, setIsCloudMode] = useState(false); const [trialDaysLeft, setTrialDaysLeft] = useState(0); + // What the trial actually grants, read from the licence once it exists, so + // this card cannot drift from the plan (it said "2 connectors" after the + // trial moved to 5). + const [trialLimits, setTrialLimits] = useState<{ connectors: number; servers: number; users: number } | null>(null); + useEffect(() => { + if (setupStep !== 'trial-activated' || !authToken) return; + license + .getStatus(authToken) + .then((st) => { + const f = st?.features ?? {}; + if (typeof f.maxConnectors === 'number' && typeof f.maxMcpServers === 'number') { + setTrialLimits({ connectors: f.maxConnectors, servers: f.maxMcpServers, users: f.maxUsers ?? 1 }); + } + }) + .catch(() => {}); + }, [setupStep, authToken]); const router = useRouter(); const searchParams = useSearchParams(); const { login } = useAuth(); const toast = useToast(); const redirectTo = safeRedirect(searchParams.get('redirect')); + // Back into an AI client's pending authorization (/auth/login, served by the + // backend): someone who came from "Connect" in Claude and had to create an + // account first. Not a Next route, so it needs a full navigation. + const returningToAuthorization = redirectTo.startsWith('/auth/'); + const goTo = (target: string) => { + if (target.startsWith('/auth/')) window.location.assign(target); + else router.push(target); + }; const emailVerifiedParam = searchParams.get('emailVerified'); const modeParam = searchParams.get('mode'); // 'register' or 'login' const ssoCode = searchParams.get('sso'); @@ -279,7 +303,10 @@ function LoginForm() { setSetupStep('verify-email'); } else { login(result.accessToken, result.user); - if (isCloudMode && needsLicenseSetup) { + if (isCloudMode && needsLicenseSetup && returningToAuthorization) { + await license.activateTrial(result.accessToken).catch(() => undefined); + goTo(redirectTo); + } else if (isCloudMode && needsLicenseSetup) { // Cloud mode: auto-activate trial for verified users setAuthToken(result.accessToken); try { @@ -289,19 +316,19 @@ function LoginForm() { // that already holds a licence (a running trial, a paid plan) goes // straight in, instead of being told it has "0 days" left. if (!trialResult.trialStarted) { - router.push(redirectTo); + goTo(redirectTo); return; } setTrialDaysLeft(trialResult.trialDaysLeft); setSetupStep('trial-activated'); } catch { - router.push(redirectTo); + goTo(redirectTo); } } else if (needsLicenseSetup) { setAuthToken(result.accessToken); setSetupStep('license-choice'); } else { - router.push(redirectTo); + goTo(redirectTo); } } } catch (err: any) { @@ -323,7 +350,11 @@ function LoginForm() { const verifiedUser = { ...storedUser, emailVerified: true }; login(authToken, verifiedUser); - if (isCloudMode) { + if (isCloudMode && returningToAuthorization) { + // Verification already created the trial; the user is in the middle + // of connecting an AI client, so take them straight back to approve. + goTo(redirectTo); + } else if (isCloudMode) { // Cloud mode: auto-activate trial try { const trialResult = await license.activateTrial(authToken); @@ -332,12 +363,12 @@ function LoginForm() { setSetupStep('trial-activated'); } catch (trialErr: any) { // Trial may already exist (e.g. returning user) — go to dashboard - router.push(redirectTo); + goTo(redirectTo); } } else if (isFirstUserFlag) { setSetupStep('license-choice'); } else { - router.push(redirectTo); + goTo(redirectTo); } } catch (err: any) { setError(err.message || 'Invalid verification code'); @@ -386,7 +417,7 @@ function LoginForm() { setLoading(true); try { await license.startBusinessTrial(authToken); - router.push(redirectTo); + goTo(redirectTo); } catch (err: any) { setError(err.message || 'Could not start the trial'); setLoading(false); @@ -402,7 +433,7 @@ function LoginForm() { setLoading(true); try { await license.setKey(licenseKey, authToken); - router.push(redirectTo); + goTo(redirectTo); } catch (err: any) { setError(err.message || 'Failed to activate license'); setLoading(false); @@ -410,7 +441,7 @@ function LoginForm() { }; const handleSkip = () => { - router.push(redirectTo); + goTo(redirectTo); }; // ── Email Verification Step ───────────────────────────────────────────── @@ -482,7 +513,7 @@ function LoginForm() { if (isFirstUserFlag) { setSetupStep('license-choice'); } else { - router.push(redirectTo); + goTo(redirectTo); } }} className="text-sm text-[var(--text-2)] hover:text-[var(--brand)] hover:underline" @@ -568,13 +599,16 @@ function LoginForm() {YOUR TRIAL INCLUDES