diff --git a/brain/knowledge/ai-intelligence/mcp-server.md b/brain/knowledge/ai-intelligence/mcp-server.md index cd27137709fb..20b9916bc108 100644 --- a/brain/knowledge/ai-intelligence/mcp-server.md +++ b/brain/knowledge/ai-intelligence/mcp-server.md @@ -6,6 +6,12 @@ icon: ๐Ÿ”Œ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop, Cursor, Windsurf) can read and manipulate flows, connections, tables, and runs through a typed tool interface. One `McpServer` record per project (UNIQUE `projectId`), authenticated by a bearer token. Available in CE, EE, and Cloud. +### Vocabulary + +**Grant** โ€” one row of `mcp_oauth_token`: this user's live authorisation for one registered client. The unit the connect page lists and revokes, named `McpOAuthGrant` and served from `/v1/mcp-oauth/grants`. +**Client** โ€” one `mcp_oauth_client` registration row. Not a stable identity: Claude Code and Codex re-run DCR per sign-in, so one client-as-a-product yields many rows, and one user re-authenticating yields many grants. _Avoid_: using "client" for the thing being revoked. +**Connection** โ€” belongs to piece auth (`AppConnection`), never to MCP. _Avoid_: "MCP connection" in code; the tab label "Connections" and the `/mcp-server/connections` URL are deliberate copy, not the domain term โ€” the code under `app/routes/mcp-server/grants/` says grant. + ### Entities & services - **McpServer** โ€” per-project record: `id`, `projectId` (unique), `token` (72-char), `disabledTools[]` (JSONB, nullable; `null`/`[]` means all controllable tools enabled). diff --git a/brain/knowledge/connections-auth/ce-authentication.md b/brain/knowledge/connections-auth/ce-authentication.md index 8667d91df6f4..0df4364eeb48 100644 --- a/brain/knowledge/connections-auth/ce-authentication.md +++ b/brain/knowledge/connections-auth/ce-authentication.md @@ -19,7 +19,7 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - **`signUp` has two arms and only one of them can create a platform.** When `params.platformId` is set (self-hosted, or a custom domain) the member joins that existing platform through `getOrCreateWithProject` and no platform is ever created or named. When it is nil (Cloud only) the identity is created first, then `getPreferredPlatformId` looks for a platform the identity already belongs to; finding none it hands off to `authenticationUtils.provisionOrOnboard`, which creates the platform straight away when the identity already carries a name someone gave us, and only falls back to an ONBOARDING response (finished at `/create-platform`) when the name is the placeholder derived from the email. `getPreferredPlatformId` returns null on every non-Cloud edition. **The member never types a platform name; they type their own, and the platform name is derived from it.** `completeSignUp` takes a single `fullName` field (that is the whole of `CompleteSignUpRequest`) and calls `signupNames.platformNameFromSignup`, which prefers the company read off a work email domain (`"Activepieces"`) and falls back to the person (`"'s Platform"`, then the capitalised first token of the email local part, then `"My Platform"`). The project name follows from the platform name via `personalProjectName`. - **ONBOARDING** is the pre-platform principal: `authenticationUtils.getOnboardingResponse` mints it with `platformId: null, projectId: null` for a verified identity that belongs to no platform yet **and whose name we only guessed**, so the member can call `POST /v1/platforms` (`securityAccess.unscoped([ONBOARDING, USER])`) and land on `/create-platform`. It is Cloud-only in practice, because on self-hosted `platformUtils.getPlatformIdForRequest` falls back to `getOldestPlatform()` and there is always a platform to join. `accessTokenManager.assertUserSession` still revalidates it against `tokenVersion` + `verified`. - **Sign-up address validation** is one call to ZeroBounce (`zerobounce.maySignUp`), from `signUp` for the EMAIL provider and from `requestCode` for an address with no identity yet. It runs only when `AP_ZEROBOUNCE_API_KEY` is set, refuses the abuse half of `do_not_mail` plus `spamtrap`/`abuse`, and fails open on anything it cannot read. Both call sites refuse **silently**, and the lib throws nothing: `requestCode` returns the same `204` as a success (no identity, no code), and `signUp` throws `EMAIL_IS_NOT_VERIFIED`, the response a genuine unverified Cloud sign-up already produces. `DOMAIN_NOT_ALLOWED` is not used here at all. See [000032](../decisions/000032-a-signup-address-is-checked-against-zerobounce-not-a-bundled-blocklist.md). -- **Passwordless sign-in** (`EMAIL_LOGIN`) is a typed 6-digit code on the same OTP primitive, offered only when `ApFlagId.SMTP_CONFIGURED` is true, with password as the fallback path. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md) for the code-not-link, edition-reach and anti-enumeration reasoning. +- **Passwordless sign-in** (`EMAIL_LOGIN`) is a typed 6-digit code on the same OTP primitive, with password as the fallback path. Its two unauthenticated routes, `/otp/request` and `/otp/verify`, live in `passwordlessAuthModule`, registered **only in the Cloud arm of `app.ts` and only when `turnstile.isConfigured()`** โ€” everywhere else they do not exist, so a self-hosted instance answers 404. `/complete-sign-up` stays in `authenticationModule` on every edition, because it finishes any ONBOARDING principal and not just a code sign-up. The UI reads one flag, `ApFlagId.EMAIL_CODE_AUTH_ENABLED`, which carries that same pair of conditions. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md) for the code-not-link and anti-enumeration reasoning and [000032](../decisions/000032-emailed-sign-in-codes-are-served-on-cloud-only-and-only-behind-a-captcha.md) for why the reach narrowed. ### Gotchas - Email-auth checks and domain allow-listing guards are **skipped on Community** edition. @@ -36,9 +36,9 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - **A new unauthenticated endpoint must be added to `disallowedRoutes` in `packages/web/src/lib/api.ts`**, otherwise the SPA attaches whatever stale bearer token is still in storage and the call fails in exactly the situation the endpoint exists for. - **The three signup guards in `authentication-utils.ts` differ in what they leak.** `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` describe platform configuration, so surfacing their errors is safe. `assertUserIsInvitedToPlatformOrProject` describes one address, so surfacing it turns any public auth endpoint into an invitation oracle. All three are also inert unless `plan.ssoEnabled`. - **We ask for a name only when we do not already have one, and `signupNames.isPlaceholderName` is what decides.** A name counts as a placeholder when the last name is empty *and* the first name matches `firstNameFromEmail` for that address case-insensitively โ€” exactly what `requestCode` seeds an emailed-code identity with. Anything else provisions the platform without a second question, and the two other producers of a name cannot collide with the placeholder shape: `SignUpRequest` types `firstName`/`lastName` as `SAFE_STRING_PATTERN` (`^[^./]+$`, so an empty last name is a 400 at the schema, not just a required field in the form), and the Google callback substitutes `'john'`/`'doe'` when the provider omits a name. The comparison must stay case-insensitive: `requestCode` derives the name from the raw address while the identity stores it lowercased, so `AhmadTash@โ€ฆ` would otherwise look like a name its owner typed. -- **A nil `projectId` on the principal means "go to /create-platform" in four separate places.** Anything that mints a platform-less session has to satisfy all of them, not just the route guard. +- **A nil `platformId` on the response means "go to /create-platform" in four separate places** โ€” `redirect.tsx`, `sign-in-form.tsx`, `sign-up-form.tsx` and the email-code step of `auth-drawer-body.tsx`. It used to be `projectId`, which stopped being a valid proxy once `getProjectAndToken` returned `projectId: null` instead of throwing: a provisioned member on a platform with `autoCreatePersonalProjects` off has a platform and no project, and the old test sent them to a name step whose `POST /complete-sign-up` rejects a USER token with 403. Anything that mints a platform-less session has to satisfy all four, not just the route guard. - **Platform naming reads the email domain first, and "is this a work address" is a denylist of consumer brands.** `ahmad@activepieces.com` yields `"Activepieces"` while `ahmad@gmail.com` yields `"Ahmad's Platform"`. Two details are easy to get wrong when touching `signup-names.ts`. The denylist is keyed on the **registrable label**, not the full domain, so `yahoo.co.uk` is caught by the single entry `yahoo`. And the label is picked as the second-to-last domain part, stepping back one more when the part before the TLD is itself a public suffix (`co`, `com`, `ac`, ...), so `mail.activepieces.com`, `activepieces.co.uk` and `eu.activepieces.co.uk` all resolve to `Activepieces` rather than to `Mail`, `Co` or `Eu`. It is a heuristic, not a public-suffix list: a company sitting on an unlisted two-part suffix gets the suffix as its name. Only new signups are affected; existing platforms keep their names. -- **The route no longer decides sign-in vs sign-up โ€” the card does.** `/sign-in`, `/sign-up` and `/create-platform` all render the same `AuthLanding`; `/sign-up` is a bare redirect to `/sign-in`. Which form you get is a function of two flags: with `SMTP_CONFIGURED` the card opens on the email-code step and the classic password form exists *only* behind the "Use password" link; without it you land on a password form directly, and `USER_CREATED` picks sign-up (first ever account, no mode switch offered) over sign-in. So the same URL renders three different DOMs across Cloud, a seeded self-host, and a fresh install โ€” anything scripting this screen has to branch, and password sign-*up* is simply unreachable once SMTP is on. +- **The route no longer decides sign-in vs sign-up โ€” the card does.** `/sign-in`, `/sign-up` and `/create-platform` all render the same `AuthLanding`; `/sign-up` is a bare redirect to `/sign-in`. Which form you get is a function of three flags: with `EMAIL_CODE_AUTH_ENABLED` and `SMTP_CONFIGURED` the card opens on the email-code step and the classic password form exists *only* behind the "Use password" link; without it you land on a password form directly, and `USER_CREATED` picks sign-up (first ever account, no mode switch offered) over sign-in. So the same URL renders three different DOMs across Cloud, a seeded self-host, and a fresh install โ€” anything scripting this screen has to branch, and password sign-*up* is reachable behind it only when the card is in sign-up mode โ€” the first-ever account, or an invitation link carrying `?email=`, since the passwordless method step renders no mode switch. - **The sign-in URL's query string survives the email-code journey but not a federated one.** `/sign-up` forwards its search to `/sign-in`, and the card never navigates, so `?foo=bar` is still there at the end. Google/SAML instead do `window.location.href = โ€ฆ` and only `from`, `providerName` and `activepiecesLogin` ride along in the OAuth `state`; the customer returns on `/redirect` and goes to `from` or `/create-platform`. Anything that has to outlive sign-in for *every* provider belongs in `localStorage`, not in the URL. - **`from` gets you back to the route but not to its query string โ€” `AuthenticatedDefaultRoute` used to drop it.** Both `DefaultRoute` and `AllowOnlyLoggedInUserOnlyGuard` build `from` as `location.pathname + location.search`, so a param on the original URL survives sign-in and `useRedirectAfterLogin` navigates back to it. The last hop was where it died: landing on `/` authenticated renders `AuthenticatedDefaultRoute`, which navigated to `determineDefaultRoute(...)` with no `search`, so anything hanging off `/?x=1` was gone before the project routes (and the guards mounted inside them) rendered. That `Navigate` now forwards a single allow-listed param (`TRIAL_KEY_QUERY_PARAM`, in `route-utils.ts` beside `NEW_FLOW_QUERY_PARAM`), which is what lets a trial activation link reach the signed-in screen that consumes it. It deliberately does **not** forward the whole search string: `AuthenticatedDefaultRoute` also serves the `/*` catch-all, so blanket forwarding would push the query string of every unmatched URL into the default route for whatever page later sits there to read. A param that must survive that hop has to be added to the allow-list. - **`/create-platform` is that same card opening on its name step**, off the ONBOARDING token rather than a route param โ€” submitting the name is what mints the platform and project and swaps ONBOARDING for USER. The field is the *person's* `Full Name` (`data-testid="auth-full-name"`), not a workspace name. **Only the emailed-code path reaches it**: password sign-up and Google already collected a name, so those sessions are provisioned in the same request and land in the product with one form submission. diff --git a/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md b/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md index 1c0df533c6ce..9a3b5207c956 100644 --- a/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md +++ b/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md @@ -18,7 +18,7 @@ Enterprise auth layer extending CE with SAML 2.0 SSO, Google/GitHub federated OA - **RBAC**: `assertPrincipalAccessToProject({principal, permission, projectId})` and `assertUserHasPermissionToFlow` (maps FlowOperationType โ†’ Permission). Authorization hooks: `platformMustHaveFeatureEnabled` (402 FEATURE_DISABLED), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. ### Gotchas -- **Until the passwordless work, CE could not send an OTP at all, despite the entity being registered for every edition.** `otpModule` was registered only in the CLOUD and ENTERPRISE arms of `app.ts`, and `emailService.sendOtp` returned early when the edition was neither. So on CE the table existed, the migration ran, and nothing could ever be sent. `EMAIL_LOGIN` changed that: `otpModule` is now registered for COMMUNITY too, and `EMAIL_LOGIN` is the one type carved out of the paid-edition send gate, so it reaches every edition while the UI gates it on `SMTP_CONFIGURED`. The two link types are still paid-edition only. RBAC base types are CE; **SSO, managed auth, federated OAuth are EE/Cloud only**. +- **Until the passwordless work, CE could not send an OTP at all, despite the entity being registered for every edition.** `otpModule` was registered only in the CLOUD and ENTERPRISE arms of `app.ts`, and `emailService.sendOtp` returned early when the edition was neither. So on CE the table existed, the migration ran, and nothing could ever be sent. `EMAIL_LOGIN` changed that: `otpModule` is now registered for COMMUNITY too, and `EMAIL_LOGIN` is the one type carved out of the paid-edition send gate, so the primitive reaches every edition. **The sign-in flow on top of it does not** โ€” [000032](../decisions/000032-emailed-sign-in-codes-are-served-on-cloud-only-and-only-behind-a-captcha.md) put its three routes in a Cloud-only module that also needs a configured captcha, so the carve-out is currently unreachable and CE can still send nothing. The two link types are still paid-edition only. RBAC base types are CE; **SSO, managed auth, federated OAuth are EE/Cloud only**. - **The public `POST /v1/otp` route deliberately cannot mint a login code.** Its `CreateOtpRequestBody` narrows `type` to `EMAIL_VERIFICATION | PASSWORD_RESET`, because that route is unauthenticated, carries no `rateLimit` config, and applies none of the sign-up guards. `EMAIL_LOGIN` is issued only through `POST /v1/authentication/otp/request`, which is rate limited and gated. Widening that enum back to the whole `OtpType` hands anyone an unthrottled "email a working sign-in code to this address" primitive. - **A code sign-in must re-assert the platform's auth policy at verify time, not only at request time.** On Cloud `platformUtils.getPlatformIdForRequest` returns null for every unauthenticated request, so the request-scoped branch never runs there and the platform is only known after the identity is resolved. `verifyCode` therefore calls the same `assertEmailAuthIsEnabled` + `assertDomainIsAllowed` pair on the resolved preferred platform; without that, an email code signs a member into a platform that has deliberately disabled email auth or removed their domain. It is not asserted at request time on purpose, because reporting those errors for a resolved address would turn the request endpoint into an existence oracle. - **`otpService.confirm` used to refresh its own resend lock.** `updated` is an `updateDate` column, so marking a row CONFIRMED touched it and the ten-minute guard then refused to issue that identity another code for ten minutes after a successful verify. Rows are deleted on confirm now. diff --git a/brain/knowledge/connections-auth/index.md b/brain/knowledge/connections-auth/index.md index 279f2a8f99ed..5cb3ecc38856 100644 --- a/brain/knowledge/connections-auth/index.md +++ b/brain/knowledge/connections-auth/index.md @@ -29,7 +29,7 @@ User identity, sign-in, JWT sessions. `UserIdentity` = canonical email+password+ ### EE Authentication -Extends CE with SSO + RBAC. SAML 2.0 (`/v1/authn/saml/login` โ†’ IdP โ†’ ACS `/acs`) and Google/GitHub federated OAuth both funnel into `authenticationService.federatedAuthn()`; gated by `ssoEnabled`. Per-project RBAC via `assertPrincipalAccessToProject()` and `assertUserHasPermissionToFlow()`. Config stored on `platform.federatedAuthProviders`. Authz hooks: `platformMustHaveFeatureEnabled` (402), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. OTP (email verify, password reset, and the `EMAIL_LOGIN` sign-in code) lives here. Its entity is registered for every edition, but `otpModule` is only registered on Cloud/EE and `sendOtp` returns early off those editions, so CE can send nothing today except `EMAIL_LOGIN`, which is gated on `SMTP_CONFIGURED` instead. +Extends CE with SSO + RBAC. SAML 2.0 (`/v1/authn/saml/login` โ†’ IdP โ†’ ACS `/acs`) and Google/GitHub federated OAuth both funnel into `authenticationService.federatedAuthn()`; gated by `ssoEnabled`. Per-project RBAC via `assertPrincipalAccessToProject()` and `assertUserHasPermissionToFlow()`. Config stored on `platform.federatedAuthProviders`. Authz hooks: `platformMustHaveFeatureEnabled` (402), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. OTP (email verify, password reset, and the `EMAIL_LOGIN` sign-in code) lives here. Its entity is registered for every edition and `otpModule` now covers COMMUNITY too, but `sendOtp` returns early off Cloud/EE for the two link types and the `EMAIL_LOGIN` sign-in flow is served only on Cloud behind a configured captcha, so CE can send nothing today. ### Managed Auth / Embedding (EE) diff --git a/brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md b/brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md index d0e5962a6ceb..9844ab1e3e77 100644 --- a/brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md +++ b/brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md @@ -5,7 +5,9 @@ status: accepted # Email sign-in is a typed code on the existing OTP primitive, not a second subsystem ## Decision -Passwordless sign-in adds a third `OtpType`, `EMAIL_LOGIN`, and reuses `otpService.createAndSend` / `.confirm` rather than introducing a parallel one-time-credential mechanism. The credential is a 6-digit code the member types, never a clickable link. It reaches every edition, but the UI only offers it when `ApFlagId.SMTP_CONFIGURED` is true; password sign-in stays the default path everywhere else. +Passwordless sign-in adds a third `OtpType`, `EMAIL_LOGIN`, and reuses `otpService.createAndSend` / `.confirm` rather than introducing a parallel one-time-credential mechanism. The credential is a 6-digit code the member types, never a clickable link. Password sign-in stays the default path everywhere else. + +**The edition-reach half of this decision no longer holds.** It reached every edition with SMTP configured; [000032](./000032-emailed-sign-in-codes-are-served-on-cloud-only-and-only-behind-a-captcha.md) narrowed that to Cloud behind a configured captcha. Everything below still stands. ## Context Main already carries the whole emailed-code mechanism: a `PENDING`/`CONFIRMED` state machine, a unique index on `(identityId, type)`, a public request endpoint, and an emailed delivery path. What it lacked was shape and reach. The value was a `randomUUID()` delivered as a magic link, there were only two `OtpType` members, `otpModule` was registered for CLOUD and ENTERPRISE only, `sendOtp` returned early when `EDITION_IS_NOT_PAID`, and there was no code-entry UI on the web at all. @@ -16,7 +18,7 @@ A vibe-coded branch built this as new machinery and regressed three properties i **A typed code, not a link.** [000009](./000009-approval-links-require-a-post-confirmation-on-a-dedicated-route.md) established that Microsoft Safe Links, Mimecast and Proofpoint pre-fetch emailed URLs with a GET that is indistinguishable from a human click. A single-use sign-in link is consumed by that prefetch, so the member's own click lands on an expired credential. A typed code sidesteps the whole class. Shipping a link would mean rebuilding 000009's GET-page plus POST-confirm shape for auth. -**Reach is gated on SMTP, not on edition.** `emailSender` silently falls back to `logEmailSender` when SMTP is unset, so an all-editions rollout without a gate is exactly the "looks enabled, silently broken" failure `.claude/rules/self-hosting.md` forbids. Gating on the already-public `SMTP_CONFIGURED` flag means a CE instance without SMTP sees no change at all, and one with SMTP gets the feature for free. No new flag, and `EMAIL_LOGIN` is the only type carved out of the paid-edition delivery gate. +**Reach is gated on SMTP, not on edition** โ€” superseded by [000032](./000032-emailed-sign-in-codes-are-served-on-cloud-only-and-only-behind-a-captcha.md), which found that a captcha nobody sets is what the budget actually rests on. `emailSender` silently falls back to `logEmailSender` when SMTP is unset, so an all-editions rollout without a gate is exactly the "looks enabled, silently broken" failure `.claude/rules/self-hosting.md` forbids. Gating on the already-public `SMTP_CONFIGURED` flag means a CE instance without SMTP sees no change at all, and one with SMTP gets the feature for free. No new flag, and `EMAIL_LOGIN` is the only type carved out of the paid-edition delivery gate. **A code request never becomes an oracle.** The three signup asserts split by what they reveal. `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` are properties of platform configuration and throw distinct errors, because knowing them tells an attacker nothing about a specific address. `assertUserIsInvitedToPlatformOrProject` reveals whether *that* address was invited, so an un-invited request returns the same 204 as success, sends nothing, and creates nothing. This mirrors the silent return `createAndSend` already uses for unknown emails. @@ -24,7 +26,7 @@ A vibe-coded branch built this as new machinery and regressed three properties i **Resend delivers the same code, it does not mint a new one.** One `TEN_MINUTES` constant served as both the expiry and the resend suppression, so `createAndSend` returned without sending until the existing code expired. That is a spam guard for a link and a ten minute lockout for a code that landed in spam. Resend now re-sends the existing value and leaves `updated` alone, so the original expiry still governs and both emails carry the same code. Minting a fresh code per resend was rejected because members type the first code they see, so reissuing invalidates the one half of them are already reading. -**Verifying a code lands the member in the product, with no naming step.** Today a brand-new Cloud identity gets an ONBOARDING response and has to name its platform at `/create-platform` before it can do anything. The code path skips that: on Cloud, when the verified identity belongs to no platform, `verifyCode` creates one through `createPlatformWithProject` with a name derived from the email local part, and returns a full session. Renaming stays available in settings. This is Cloud-only by construction, because self-hosted sign-up takes the other `signUp` arm and joins the platform that already exists. It also means the passwordless path never mints an ONBOARDING principal; that window remains only for the password and federated paths. +**Verifying a code lands the member in the product, with no naming step.** Today a brand-new Cloud identity gets an ONBOARDING response and has to name its platform at `/create-platform` before it can do anything. The code path skips that: on Cloud, when the verified identity belongs to no platform, `verifyCode` creates one through `createPlatformWithProject` with a name derived from the email local part, and returns a full session. Renaming stays available in settings. This is Cloud-only by construction, because self-hosted sign-up takes the other `signUp` arm and joins the platform that already exists. That naming step later came back as `/complete-sign-up`, so the passwordless path does mint an ONBOARDING principal when the name it holds is the placeholder derived from the email. **The OTP module moves out of `ee/`.** Making `EMAIL_LOGIN` all-editions makes the primitive all-editions, so `ee/authentication/otp/` becomes `authentication/otp/` (four importers). This clears a standing `.claude/rules/edition-safety.md` violation rather than adding a second one, and it removes the trap the directory name set: the brain page already asserted "CE gets OTP flows" while the module was registered for Cloud and Enterprise only. A `hooksFactory` seam was rejected as one interface with one implementation around a primitive every edition now runs. diff --git a/brain/knowledge/decisions/000032-emailed-sign-in-codes-are-served-on-cloud-only-and-only-behind-a-captcha.md b/brain/knowledge/decisions/000032-emailed-sign-in-codes-are-served-on-cloud-only-and-only-behind-a-captcha.md new file mode 100644 index 000000000000..bd3c6a5564c0 --- /dev/null +++ b/brain/knowledge/decisions/000032-emailed-sign-in-codes-are-served-on-cloud-only-and-only-behind-a-captcha.md @@ -0,0 +1,55 @@ +--- +status: accepted +--- + +# Emailed sign-in codes are served on Cloud only, and only behind a captcha + +## Decision +`/otp/request` and `/otp/verify` move out of `authentication.controller.ts` into their own `passwordlessAuthModule`, +registered inside the `ApEdition.CLOUD` arm of `app.ts` and only when `turnstile.isConfigured()`. Everywhere else the +two routes do not exist. `/complete-sign-up` deliberately **stays** in `authenticationModule` on every edition: it is +not part of the emailed-code surface, it is what finishes any ONBOARDING principal, and since #15083 +`provisionOrOnboard` hands those out from `signUp`, `signInWithPassword` and `federatedAuthn` too. Moving it would +brick every in-flight onboarding session the moment a captcha key went missing. A missing captcha is logged and the module +registers nothing; it is not a boot failure. `ApFlagId.EMAIL_CODE_AUTH_ENABLED` carries the same pair of conditions so +the form the UI offers always matches what the server will answer. + +This reverses the "reach is gated on SMTP, not on edition" call in +[000027](./000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md); everything else in 000027 stands. + +## Context +A six-digit code is 10^6 possibilities, and what keeps guessing it expensive is the captcha in front of the request +endpoint โ€” the only unauthenticated pair here โ€” not the per-credential attempt counter โ€” five wrong guesses discards the code, and asking for another hands +out five more. Turnstile is opt-in and unset out of the box, so a self-hosted instance served both endpoints with +nothing in front of them. 0.88.2 through 0.88.4 shipped that way: `authenticationModule` is registered +unconditionally, and the UI offered the code card on any edition with SMTP configured. + +## Why +Hardening a path self-hosters never asked for buys less than not serving it. An absent route is a stronger guarantee +than any limit inside one, and it needs no configuration to hold โ€” which is what +`.claude/rules/self-hosting.md` asks of anything that would otherwise need a key nobody set. + +The rejected alternative was to keep the routes everywhere and require the captcha, failing boot without keys. That +takes an operator's flows, webhooks and instance down to enforce one sign-in method, which is out of proportion to +what is being protected. Registering nothing gives the same property โ€” the feature cannot run without a captcha โ€” +and costs the operator only that method. + +Stating the flag's condition in `flag.service.ts` rather than exporting it from the module is deliberate: +`passwordless-auth.service` imports `flagService`, so importing the module into the flag service closes an import +cycle. + +## Consequences +- Removing the three routes is a **breaking change** for self-hosters on 0.88.2โ€“0.88.4 who had SMTP configured โ€” they + were offered emailed codes and now are not. `docs/install/reference/breaking-changes.mdx` carries the entry. +- **On Community those accounts are locked out with no in-product way back.** `requestCode` gives a new identity a + random password, and the only writer of a password is `enterpriseLocalAuthnService`, whose module `app.ts` registers + in the Cloud and Enterprise arms only โ€” so Community serves no forget-password route and nothing else can set one. + The operator has to write a hash onto the `user_identity` row. Enterprise and Cloud recover through + **Forgot password**. +- A Cloud environment without `AP_TURNSTILE_SITE_KEY` / `AP_TURNSTILE_SECRET_KEY` starts normally and simply does not + offer emailed codes; the UI falls back to the password forms because the flag carries the same condition. Cloudflare + publishes always-pass test keys (`1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`) for previews. +- Two plugins now register under the `/v1/authentication` prefix. Anything added to one is absent from the other, + so a shared hook or decorator has to go on both. +- The `EMAIL_LOGIN` carve-out from the paid-edition send gate in `email-service.ts`, which 000027 added to give CE + reach, is now unreachable. It is left in place rather than removed, so the primitive stays edition-neutral. diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 566a7f0bc9dd..ff6b73394fe8 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -45,3 +45,5 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **Reopening a bot-closed external PR is futile until a core member adds `keep-open` first.** `close-external-prs.yml` triggers on `pull_request_target` `[opened, reopened]`, so every reopen re-runs the same comment-then-close step; its `if` exempts OWNER/MEMBER/COLLABORATOR, bots, and the `keep-open` label, and nothing else. A docs PR from an outside contributor ([#15031](https://github.com/activepieces/activepieces/pull/15031)) was reopened 13 times over two days and closed 13 times within seconds of each, until a member labelled it `keep-open` and reopened it once. The same job also runs a nightly `actions/stale` pass that closes any PR idle 60 days. The lasting fix for a change worth keeping is to re-open it from a branch owned by someone with write access โ€” author association, not the diff, is what the gate reads. - **`license/cla` keys off the commit author email, so re-opening someone else's branch under your own name does not clear it.** CLA-assistant walks every commit in the PR rather than the PR author, and an author email that matches no GitHub account can never be matched to a signature โ€” the 47 commits carried over onto [#15092](https://github.com/activepieces/activepieces/pull/15092) were authored as `ashrafsam@mac.lan`, a local hostname, so the check sat at `not_signed` on a PR opened by a member. It is not in the `main` ruleset's required-checks list, but it is red on the page and a reviewer reads that as unmergeable. Either the original author signs through the PR link, or the commits get re-authored to an email tied to their GitHub account before you open it. - **A branch that predates the `brain/` โ†’ `brain/knowledge/` move cannot edit a brain page in place โ€” GitHub will call the PR conflicting even when `git merge` is clean locally.** Git follows the rename and merges the modification into the new path; GitHub's mergeability check does not, so it reports `modify/delete` on the old path and the PR goes `dirty`. Local `git merge-tree --write-tree` exits 0 and hides the problem; reproduce what GitHub sees with `git merge -X no-renames origin/main`. Fix: merge `origin/main` into the branch first, which lands the edit at the new path, then push. +- **`breaking-change-check` couples the docs entry to the label in BOTH directions, so back-documenting an already-shipped change drags the label onto a docs-only PR.** R3 in `tools/scripts/breaking-change-check.ts` fails a PR that adds a `####` entry to `docs/install/reference/breaking-changes.mdx` without `โ›“๏ธโ€๐Ÿ’ฅ breaking-change`, exactly as it fails the label without an entry โ€” and the template answer has to agree too, so "yes" must be ticked on a PR that changes no code. It reads the *added lines of that one file* from `git diff origin/...HEAD`, and `hasBreakingEntry` wants a `####` heading **plus** a non-heading body line, so a heading alone, a `---`, or a version bump does not count. Two consequences: the label then collides with `skip-changelog` in release-drafter (pick one deliberately โ€” the feature's own PR usually already carried the changelog entry), and an entry appended to a *released* section still trips it, since the check never looks at which heading the lines landed under. +- **Nothing rolls `## Unreleased` over at release time, and the docs site is unversioned โ€” so a breaking-changes entry has to name its own version.** No workflow or script writes to `docs/install/reference/breaking-changes.mdx` (`breaking-change-check.ts` only reads it), and `git log -S"## 0.88"` on the file comes back empty: the heading has not moved since 0.87.0, so entries for work that shipped months ago still sit under "Unreleased" (PM2 removal in 0.88.2, cache pre-warm gate and workspace naming in 0.89.0, โ€ฆ). `docs/docs.json` has no versioning either, so there is one live page for every self-hoster whatever version they run, published on merge rather than on release โ€” the version heading is the *only* thing telling a reader whether a change is already in their build. So before adding an entry, run `git tag --contains ` on the change it describes and file it under the release that actually shipped it; only genuinely unshipped work belongs under "Unreleased". What points self-hosters at the page in the first place is `release-drafter.yml`, which appends a "review the Breaking Changes page" line to every release body and groups `โ›“๏ธโ€๐Ÿ’ฅ breaking-change` PRs under their own heading โ€” which also means a docs-only PR back-documenting an old change shows up in the *next* release's breaking-change list. diff --git a/brain/knowledge/engineering/web-feature-anatomy.md b/brain/knowledge/engineering/web-feature-anatomy.md index 5a0e33037ba1..365580358d10 100644 --- a/brain/knowledge/engineering/web-feature-anatomy.md +++ b/brain/knowledge/engineering/web-feature-anatomy.md @@ -81,3 +81,5 @@ Verify with `npx turbo run lint --filter=web`, or `npm run lint-dev` for the who - **The layering is lint-enforced, not just a convention.** `packages/web/.eslintrc.json` has an `import/no-restricted-paths` zone making the codebase unidirectional: `src/app` may import `src/features`, and both may import `src/lib`/`hooks`/`components`/`types`/`utils` โ€” never the reverse (the one exception is `app/query-client.ts`). So a hook that a public route needs belongs in `src/lib`, but anything rendering a feature's components has to live in that feature; you cannot keep the pair in one `lib` file. It fails as an `import/no-restricted-paths` **error**, not a warning, so it blocks lint. - **Arbitrary Tailwind values for type, tracking and radius get sent back in review โ€” `packages/web` has its own scale and it is not stock Tailwind.** There is no `tailwind.config.js`; this is Tailwind v4 and the theme lives in the `@theme` block of `src/styles.css`, which *adds* `--text-xss: 0.65rem`, *overrides* `--text-3xl` to 1.75rem and `--text-4xl` to 2rem (both smaller than stock), and derives `--radius-{sm,md,lg,xs,xss}` from a single `--radius: 0.5rem`. So `text-[13px]`, `tracking-[-0.025em]` and `rounded-[11px]` are not just style nits โ€” they sit *between* real tokens and drift the page off the scale. Map them: 10โ€“11px โ†’ `text-xss`, 11.5โ€“12.5px โ†’ `text-xs`, 13โ€“13.5px โ†’ `text-sm`, 15โ€“15.5px โ†’ `text-base`; negative tracking โ†’ `tracking-tight`, uppercase-eyebrow tracking โ†’ `tracking-wide`/`wider`; any `rounded-[9โ€“11px]` โ†’ `rounded-md`. Layout constraints are the exception and stay arbitrary โ€” `max-w-[628px]` for a reading measure or `lg:w-[344px]` for a sidebar have no token equivalent and are idiomatic. Fractional spacing (`size-5.5`, `size-8.5`, `size-13`) is valid in v4 and beats `size-[22px]`. Neither eslint nor `tsc` catches any of this, so it only ever surfaces in review. - **`npx prettier --check` lies about `packages/web` โ€” it flags files nobody has touched, so never treat it as a gate.** Prettier is not in any CI workflow, and the root `.prettierrc` is a single `{"singleQuote": true}` while the resolved binary is prettier **2.8.4**, whose `trailingComma` default is `es5`. The checked-in code is formatted by prettier 3 (via the editor / eslint integration), which defaults to `all` โ€” so every multi-line call with a trailing comma reads as a "code style issue". Running `--check` on a file straight out of `git show HEAD:` reproduces it. If you want to know whether your own edit is formatted, diff `npx prettier ` against the file and check the hunks are yours; the pass/fail verdict is meaningless. `npx turbo run lint --filter=web` is the real gate. +- **A date test with hardcoded `Z` fixtures is a false green โ€” CI runs UTC, and both `dayjs().isSame(x, 'day')` and `formatUtils.formatDate` are *local*.** Freezing the clock with `vi.setSystemTime(new Date('โ€ฆZ'))` and then asserting against a literal `'2025-09-15T00:30:00Z'` only holds where local time is UTC. `grant-utils.test.ts` on [#15079](https://github.com/activepieces/activepieces/pull/15079) was 3/3 green in CI and on `TZ=UTC`, 1 failed on `TZ=America/New_York` (`00:30Z` is the *previous* local day, so "Active today" flips to "Last used Yesterday"), 2 failed on `TZ=Pacific/Honolulu` (the second being `formatDate` rendering `Aug 11` where the test asserted `Aug 12`). Nobody in the Americas can run the suite clean, and nothing in CI will ever tell you. Derive every fixture from the frozen clock instead of writing a literal โ€” `dayjs(NOW).startOf('day').add(30, 'minute')`, `dayjs(NOW).subtract(34, 'day')` โ€” and assert with the same local formatter the code uses (`earlier.format('MMM D')`), so fixture and assertion move together in any zone. Check any new date test with `TZ=America/New_York` and `TZ=Pacific/Honolulu` before pushing; those two straddle UTC on both sides and catch it. The production `isSame(โ€ฆ, 'day')` is *correct* โ€” a user's "today" is their own day โ€” so the bug is always in the test, never in the formatter. +- **`ConfirmationDeleteDialog`'s `entityName` is a required prop that renders nowhere unless you also pass `showToast` โ€” 25 of its 30 call sites compute a label and throw it away.** `components/custom/delete-dialog.tsx` mentions `entityName` three times: the prop type, the destructure, and one `toast.success(t('Removed {entityName}', โ€ฆ))` sitting inside `if (showToast)`. `showToast` is optional and there is no default, so every caller that omits it (or passes `false`) gets no toast and no other use of the value. The dialog body renders `title` and `message` only, so the confirmation never names what is about to be deleted. `project-member-card.tsx` builds `` `${firstName} ${lastName}` `` for nothing; `api-keys/index.tsx` passes `t('API Key')` for nothing. Nothing catches it โ€” the prop is required, so TypeScript is satisfied, and lint has no opinion. Caught on [#15079](https://github.com/activepieces/activepieces/pull/15079), where it also made a newly added `revokedGrants` ICU plural rule unreachable in every locale โ€” a dead translation key that `i18n:extract` will happily keep regenerating. When you want the name on screen, interpolate it into `message` yourself (`t('Revoking {entityName}. โ€ฆ', { entityName: label })`); passing `entityName` alone does nothing. Before adding a translation key for a dialog label, grep for where the prop you are feeding actually renders. diff --git a/bun.lock b/bun.lock index db140d238989..6e37ad474b4d 100644 --- a/bun.lock +++ b/bun.lock @@ -162,7 +162,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.151.0", + "version": "0.153.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -3474,15 +3474,17 @@ }, "packages/pieces/community/front": { "name": "@activepieces/piece-front", - "version": "0.1.6", + "version": "0.2.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", + "form-data": "4.0.6", }, "devDependencies": { "tslib": "^2.3.0", + "vitest": "3.2.6", }, }, "packages/pieces/community/gameball": { @@ -3678,7 +3680,7 @@ }, "packages/pieces/community/gmail": { "name": "@activepieces/piece-gmail", - "version": "0.13.0", + "version": "0.14.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3697,6 +3699,7 @@ "@types/mime-types": "2.1.1", "@types/nodemailer": "7.0.11", "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/goodmem": { @@ -6120,7 +6123,7 @@ }, "packages/pieces/community/moxie-crm": { "name": "@activepieces/piece-moxie-crm", - "version": "0.1.8", + "version": "0.2.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6129,6 +6132,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/muna-ai": { diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index f903c9201357..aea1a2b5f6a5 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -143,6 +143,28 @@ This affects the Tables piece's Find Records action and any direct API call that Nothing on upgrade. Re-check any flow or API integration that filters a Date column with `gt`, `gte`, `lt` or `lte` โ€” it now returns the rows the filter actually describes, which may be more or fewer than before. +## 0.88.2 + +### What has changed? + +#### Sign-in and sign-up are one screen, and an emailed code signs people in wherever SMTP is configured + +`/sign-in` and `/sign-up` now render the same card, and `/sign-up` redirects to `/sign-in` carrying its query string, so existing links โ€” invitation URLs included โ€” keep working. + +The card's primary path is a six-digit code emailed to the address typed into it. It is offered whenever the instance has SMTP configured and email auth is enabled, on every edition including Community, so a self-hosted instance that already sends transactional email gains a way to sign in that needs no password. An instance with no SMTP is unaffected: the card opens directly on the classic password form, with Google and SAML above it exactly as before. + +Two things change on an instance that does have SMTP. Password sign-in moves behind a **Use password** link on the card, and password sign-*up* is offered only where the card is in sign-up mode โ€” the first account on a fresh instance, or an invitation link (`/sign-up?email=โ€ฆ`) โ€” so someone who simply opens `/sign-in` on an instance that already has accounts is given the emailed code and no way to switch to a password sign-up form. Existing passwords are untouched: a verified identity keeps its password, and signing in with a code does not discard it. + +Who may get in does not change, and the existing guards all run before a code is sent. Joining still requires `AP_ALLOW_OPEN_SIGN_UP=true`, an accepted invitation, or existing membership, and that refusal is silent โ€” the response is identical to an accepted one and no code is sent, so the endpoint reveals nothing about who has an account. A platform's allowed-domains list still applies too, but it answers with an explicit domain-not-allowed error rather than silently, and like the platform's Email-auth setting it is inert on Community and on any platform whose plan does not include SSO. On a fresh install, before the first platform exists, there is no platform for either check to run against and both are skipped. + +#### What you need to do + +Nothing to configure or migrate, and no new environment variable: emailed-code sign-in turns itself on once SMTP is configured and Email is enabled as an authentication method. + +If you do not want emailed-code sign-in, turn off **Email** as an authentication method for the platform (Enterprise and Cloud, under SSO settings, and only where your plan includes SSO); that disables password sign-in along with it, since both are the same method. On Community that toggle is inert, so the only lever there is leaving SMTP unconfigured. + +If you script or end-to-end test the sign-in screen, re-check it: the same URL now renders a different form depending on whether SMTP is configured and whether any account exists yet. Password reset moved inside the card wherever the emailed-code path is active; an instance without SMTP still sends people to `/forget-password`, and Community shows no reset control at all. + #### Emailed sign-in codes allow ten wrong guesses per account per hour The six-digit login code already allowed five wrong guesses, but that budget lived on the code itself, and the fifth wrong guess threw the code away โ€” so asking for a new code handed out five fresh guesses immediately, with no ceiling on how often that could repeat. A six-digit code is only a million possibilities, so unlimited retries reduce it to a matter of hours. @@ -167,6 +189,67 @@ Nothing, and no new configuration: the key is derived from a secret your instanc Rolling back costs at most the codes issued after the deploy: the older build cannot read those, so whoever holds one asks for a fresh code. Nothing is rewritten and nothing is deleted, so no cleanup is needed either way. +#### Front's Attachments field takes files instead of URLs + +The Front piece's Send Message, Send Reply, Create Draft and Create Draft Reply actions described `Attachments` as a list of attachment URLs and sent those strings inside a JSON body. Front only accepts attachments on a `multipart/form-data` request, as the bytes of the file, so it took the message and dropped the attachments โ€” HTTP 202, no error, no attachment. The field is now a list of files, the same shape the Gmail and Discord pieces use, and the request is sent as multipart when a message carries attachments. + +A URL is still all you need to supply: the engine downloads it and hands the piece the resolved file. A step that already holds a file โ€” an earlier download, a trigger's attachment โ€” can be wired straight in. An entry whose file cannot be resolved is skipped rather than failing the send. + +#### What you need to do + +Re-pick the attachment in any Front step that used the field. The stored value is a plain string and the field now holds a file, so it does not carry across. A flow that never set Attachments is unaffected and keeps its JSON request path. Nothing to configure on the server, and no new environment variable. + +#### A file URL that fails to download is no longer treated as a file + +Any piece property that takes a file also accepts a URL, and the engine buffered whatever came back from that URL without checking the response status. A 4xx or 5xx produced a file named after the URL whose contents were the server's error page, and a piece could not tell that apart from the real thing โ€” an expired signed link became an email with the storage provider's XML error attached. A failed download now fails the step, which is what the streaming path already did. + +#### What you need to do + +Nothing to configure. A flow that was silently passing on error pages will start failing at that step instead, and the fix is the URL itself โ€” usually a link that has expired or that the instance is not authorized to read. + +#### Signing in with an emailed code is now offered on Activepieces Cloud only + +`POST /v1/authentication/otp/request` and `POST /v1/authentication/otp/verify` are no longer served on a self-hosted +instance. They return `404`, and the sign-in card no longer offers the "email me a code" step โ€” it opens on the +password form instead. Versions 0.88.2 through 0.88.4 did serve them on any edition with SMTP configured. +`POST /v1/authentication/complete-sign-up` is unaffected and still served everywhere. + +A six-digit code is a million possibilities, so what keeps guessing it expensive is the captcha in front of the +request endpoint. Cloudflare Turnstile is opt-in and unset out of the box, so a self-hosted instance served both +endpoints with nothing in front of them. Rather than require a Cloudflare account to make a sign-in method safe, +that method is no longer served where the captcha is not there. + +On Cloud the same rule now applies: the routes exist only when both `AP_TURNSTILE_SITE_KEY` and +`AP_TURNSTILE_SECRET_KEY` are set. Without them the instance starts normally and simply does not offer emailed +codes. Activepieces Cloud has both configured, so nothing changes for it โ€” the code step stays on the sign-in card +and accounts that use it keep signing in exactly as before. + +#### What you need to do + +Nothing, if your users sign in with a password or with Google โ€” those paths are unchanged, and so are `/sign-up`, +`/sign-in` and `/switch-platform`. + +**If you ran 0.88.2โ€“0.88.4 with SMTP configured, assume some accounts were created through the code flow.** Those +accounts hold a random password nobody was ever shown, so removing the code flow removes the only way they could +sign in. There is no reliable marker for them in the database โ€” a verified code deletes its `otp` row โ€” so treat any +`EMAIL`-provider account whose owner cannot recall setting a password as one of them. + +On **Enterprise**, send those users through **Forgot password** once to set a password; that flow needs the same SMTP +you already have configured. + +On **Community** there is no way back in the product. Password reset lives in the enterprise module, which Community +does not register, so `POST /v1/authn/local/reset-password` is not served โ€” and nothing else writes a password on any +edition, as there is no change-password endpoint. Do not be misled by the first half of that flow appearing to work: +`POST /v1/otp` **is** served on Community and answers `204`, but Community sends no OTP email of any type except a +login code, so no reset mail ever arrives. Set a password for each affected account directly โ€” +`UPDATE user_identity SET password = '' WHERE email = 'โ€ฆ'` โ€” and hand it to its owner over a channel you +trust, since they cannot change it themselves afterwards. Do this **before** you upgrade if you can, so nobody is +locked out in between. + +If you script or test the sign-in page, note that the emailed-code step is gone and the two endpoints above +answer `404`. + + ## 0.87.0 ### What has changed? diff --git a/docs/install/reference/environment-variables.mdx b/docs/install/reference/environment-variables.mdx index 280d6bf17b09..d8e207796ab4 100644 --- a/docs/install/reference/environment-variables.mdx +++ b/docs/install/reference/environment-variables.mdx @@ -229,6 +229,11 @@ The challenge is only served when **both** Turnstile variables are set. With either missing, the sign-in page renders no widget and the server verifies nothing, so a self-hosted instance needs no Cloudflare account. +Signing in with an emailed code depends on that pair. It is served on +Activepieces Cloud only, and there only when both variables are set โ€” without +them the endpoints are not registered and the sign-in card opens on the +password form. + ### Email (SMTP) Outbound mail for invitations, notifications, and password resets. diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 100ceb051f08..118f5aa4f85f 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.152.0", + "version": "0.155.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/automation/mcp/mcp-oauth.ts b/packages/core/shared/src/lib/automation/mcp/mcp-oauth.ts index ca442a5ce663..d379296e4c99 100644 --- a/packages/core/shared/src/lib/automation/mcp/mcp-oauth.ts +++ b/packages/core/shared/src/lib/automation/mcp/mcp-oauth.ts @@ -1,5 +1,6 @@ -import { BaseModelSchema } from '@activepieces/core-utils' +import { ApId, BaseModelSchema, OptionalArrayFromQuery } from '@activepieces/core-utils' import { z } from 'zod' +import { UserWithMetaInformation } from '../../core/user/user' export const McpOAuthClientKey = z.enum(['claude', 'claude-code', 'chatgpt', 'cursor', 'vscode', 'codex', 'gemini-cli', 'opencode', 'windsurf', 'unknown']) @@ -52,3 +53,34 @@ export const McpOAuthAuthorizationCode = z.object({ }) export type McpOAuthAuthorizationCode = z.infer + +export const McpOAuthGrant = z.object({ + id: z.string(), + clientKey: McpOAuthClientKey, + clientName: z.string().nullable(), + projectId: z.string().nullable(), + projectName: z.string().nullable(), + member: UserWithMetaInformation.nullable(), + created: z.string(), + lastUsedAt: z.string().nullable(), +}) + +export type McpOAuthGrant = z.infer + +export const ListMcpOAuthGrantsRequestQuery = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + projectIds: OptionalArrayFromQuery(z.string()), + memberIds: OptionalArrayFromQuery(ApId), + clientKeys: OptionalArrayFromQuery(McpOAuthClientKey), +}) + +export type ListMcpOAuthGrantsRequestQuery = z.infer + +export const RevokeMcpOAuthGrantsRequestBody = z.object({ + ids: z.array(ApId).min(1).max(100), +}) + +export type RevokeMcpOAuthGrantsRequestBody = z.infer + +export const PLATFORM_WIDE_PROJECT_FILTER_VALUE = 'platform-wide' diff --git a/packages/core/shared/src/lib/core/flag/flag.ts b/packages/core/shared/src/lib/core/flag/flag.ts index f609ff5e17be..95f18b9e2c15 100755 --- a/packages/core/shared/src/lib/core/flag/flag.ts +++ b/packages/core/shared/src/lib/core/flag/flag.ts @@ -26,6 +26,7 @@ export enum ApFlagId { CURRENT_VERSION = 'CURRENT_VERSION', EDITION = 'EDITION', EMAIL_AUTH_ENABLED = 'EMAIL_AUTH_ENABLED', + EMAIL_CODE_AUTH_ENABLED = 'EMAIL_CODE_AUTH_ENABLED', FRONTEND_SENTRY_DSN = 'FRONTEND_SENTRY_DSN', EXECUTION_DATA_RETENTION_DAYS = 'EXECUTION_DATA_RETENTION_DAYS', ENVIRONMENT = 'ENVIRONMENT', diff --git a/packages/core/shared/src/lib/ee/agent/agent.ts b/packages/core/shared/src/lib/ee/agent/agent.ts index 28777b0bb82b..16057dbc83b3 100644 --- a/packages/core/shared/src/lib/ee/agent/agent.ts +++ b/packages/core/shared/src/lib/ee/agent/agent.ts @@ -11,6 +11,7 @@ const MAX_AGENT_OUTPUT_FIELDS = 50 const MAX_AGENT_STEP_BUDGET = 1_000 const MAX_AGENT_SHARED_MEMBERS = 200 const MAX_AGENT_PAGE_SIZE = 100 +const MAX_AGENT_SEARCH_LENGTH = 200 const MAX_AGENT_NAME_LENGTH = 200 const MAX_AGENT_DESCRIPTION_LENGTH = 2_000 const MAX_AGENT_CONFIG_BYTES = 128_000 @@ -122,8 +123,16 @@ const GetAgentRequest = z.object({ includeUsage: z.coerce.boolean().optional(), }) +enum AgentListSort { + UPDATED = 'updated', + CREATED = 'created', + NAME = 'name', +} + const ListAgentsRequest = z.object({ projectId: z.optional(ApId), + search: z.optional(z.string().max(MAX_AGENT_SEARCH_LENGTH)), + sort: z.optional(z.enum(AgentListSort)), cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(MAX_AGENT_PAGE_SIZE).optional(), }) @@ -133,6 +142,8 @@ const agentUtils = { } export { + AgentListSort, + MAX_AGENT_SEARCH_LENGTH, Agent, AgentUsage, AgentWithUsage, diff --git a/packages/pieces/community/cryptolens/package.json b/packages/pieces/community/cryptolens/package.json index 4cd7a4e8b874..41ac11008233 100644 --- a/packages/pieces/community/cryptolens/package.json +++ b/packages/pieces/community/cryptolens/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-cryptolens", - "version": "0.0.8", + "version": "0.1.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/cryptolens/src/index.ts b/packages/pieces/community/cryptolens/src/index.ts index a722a1613365..59f58ab49868 100644 --- a/packages/pieces/community/cryptolens/src/index.ts +++ b/packages/pieces/community/cryptolens/src/index.ts @@ -3,6 +3,7 @@ import { cryptolensAuth } from './lib/common/auth'; import { addCustomer } from './lib/actions/add-customer'; import { blockKey } from './lib/actions/block-key'; import { createKey } from './lib/actions/create-key'; +import { extendLicense } from './lib/actions/extend-license'; import { newApiEvent } from './lib/triggers/new-api-event'; import { createCustomApiCallAction } from '@activepieces/pieces-common'; import { PieceCategory } from '@activepieces/pieces-framework'; @@ -20,6 +21,7 @@ export const cryptolens = createPiece({ addCustomer, blockKey, createKey, + extendLicense, createCustomApiCallAction({ auth: cryptolensAuth, baseUrl: () => 'https://api.cryptolens.io/api', diff --git a/packages/pieces/community/cryptolens/src/lib/actions/add-customer.ts b/packages/pieces/community/cryptolens/src/lib/actions/add-customer.ts index 43c35df047c0..c68881d173a9 100644 --- a/packages/pieces/community/cryptolens/src/lib/actions/add-customer.ts +++ b/packages/pieces/community/cryptolens/src/lib/actions/add-customer.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { makeRequest } from '../common/client'; import { cryptolensAuth } from '../common/auth'; import { HttpMethod } from '@activepieces/pieces-common'; +import { addCustomerActionOutputSchema } from '../output-schemas'; export const addCustomer = createAction({ auth: cryptolensAuth, @@ -13,6 +14,7 @@ export const addCustomer = createAction({ description: 'Creates a new customer record in a Cryptolens account, optionally enabling a customer portal so the customer can self-manage their licenses and device activations. Use to onboard a license holder before associating keys with them. Requires name, email, and company name; not idempotent โ€” each call creates a separate customer.', idempotent: false, }, + outputSchema: addCustomerActionOutputSchema, props: { name: Property.ShortText({ displayName: 'Name', diff --git a/packages/pieces/community/cryptolens/src/lib/actions/block-key.ts b/packages/pieces/community/cryptolens/src/lib/actions/block-key.ts index 5fcf88e481f7..72786543becd 100644 --- a/packages/pieces/community/cryptolens/src/lib/actions/block-key.ts +++ b/packages/pieces/community/cryptolens/src/lib/actions/block-key.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { makeRequest } from '../common/client'; import { cryptolensAuth } from '../common/auth'; import { HttpMethod } from '@activepieces/pieces-common'; +import { blockKeyActionOutputSchema } from '../output-schemas'; export const blockKey = createAction({ auth: cryptolensAuth, @@ -13,6 +14,7 @@ export const blockKey = createAction({ description: 'Blocks a specific license key for a product so it is rejected by most Cryptolens Web API methods (e.g. activation/validation). Use to revoke or suspend a license. Requires the product ID and the serial key string. Blocking is a state change, but re-blocking an already-blocked key has no additional effect.', idempotent: true, }, + outputSchema: blockKeyActionOutputSchema, props: { productId: Property.Number({ displayName: 'Product ID', diff --git a/packages/pieces/community/cryptolens/src/lib/actions/create-key.ts b/packages/pieces/community/cryptolens/src/lib/actions/create-key.ts index 3fc64bd61a6a..32e9aae1d330 100644 --- a/packages/pieces/community/cryptolens/src/lib/actions/create-key.ts +++ b/packages/pieces/community/cryptolens/src/lib/actions/create-key.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { makeRequest } from '../common/client'; import { cryptolensAuth } from '../common/auth'; import { HttpMethod } from '@activepieces/pieces-common'; +import { createKeyActionOutputSchema } from '../output-schemas'; export const createKey = createAction({ auth: cryptolensAuth, @@ -13,6 +14,7 @@ export const createKey = createAction({ description: 'Generates one or more new license keys for a Cryptolens product, with configurable validity period, feature flags (F1-F8), machine activation limits, trial activation, and optional association to a customer or reseller. Use to issue licenses for a product. Requires the product ID; not idempotent โ€” each call mints new key(s), and Number of Keys can generate up to 1000 at once.', idempotent: false, }, + outputSchema: createKeyActionOutputSchema, props: { productId: Property.Number({ displayName: 'Product ID', diff --git a/packages/pieces/community/cryptolens/src/lib/actions/extend-license.ts b/packages/pieces/community/cryptolens/src/lib/actions/extend-license.ts index 08e5ed495fd2..4d2a5128a70b 100644 --- a/packages/pieces/community/cryptolens/src/lib/actions/extend-license.ts +++ b/packages/pieces/community/cryptolens/src/lib/actions/extend-license.ts @@ -2,12 +2,20 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { makeRequest } from '../common/client'; import { cryptolensAuth } from '../common/auth'; import { HttpMethod } from '@activepieces/pieces-common'; +import { extendLicenseActionOutputSchema } from '../output-schemas'; export const extendLicense = createAction({ auth: cryptolensAuth, name: 'extendLicense', displayName: 'Extend License', description: 'Extend a license key by a specified number of days', + audience: 'both', + aiMetadata: { + description: + 'Extends the expiration date of an existing Cryptolens license key by a number of days, or shortens it with a negative number. Use to renew or adjust a license without issuing a new key. Requires the product ID and the serial key string; not idempotent โ€” each call shifts the expiration date again.', + idempotent: false, + }, + outputSchema: extendLicenseActionOutputSchema, props: { productId: Property.Number({ displayName: 'Product ID', diff --git a/packages/pieces/community/cryptolens/src/lib/common/client.ts b/packages/pieces/community/cryptolens/src/lib/common/client.ts index 9e1ced541cc3..d7972380f6a9 100644 --- a/packages/pieces/community/cryptolens/src/lib/common/client.ts +++ b/packages/pieces/community/cryptolens/src/lib/common/client.ts @@ -9,15 +9,23 @@ export async function makeRequest( body?: unknown ) { try { - const url = `${BASE_URL}${path}?token=${encodeURIComponent(access_token)}`; + const [pathname, search] = path.split('?'); + const authenticatedUrl = `${BASE_URL}${pathname}?token=${encodeURIComponent( + access_token + )}`; + const sendsFormBody = method !== HttpMethod.GET && !!search; const response = await httpClient.sendRequest({ method, - url, + url: sendsFormBody || !search + ? authenticatedUrl + : `${authenticatedUrl}&${search}`, headers: { - 'Content-Type': 'application/json', + 'Content-Type': sendsFormBody + ? 'application/x-www-form-urlencoded' + : 'application/json', }, - body, + body: sendsFormBody ? search : body, }); return response.body; } catch (error: any) { diff --git a/packages/pieces/community/cryptolens/src/lib/output-schemas.ts b/packages/pieces/community/cryptolens/src/lib/output-schemas.ts new file mode 100644 index 000000000000..d6548007e5c0 --- /dev/null +++ b/packages/pieces/community/cryptolens/src/lib/output-schemas.ts @@ -0,0 +1,56 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const envelopeFields: OutputSchema['fields'] = [ + { key: 'result', label: 'Result Code', format: 'number' }, + { key: 'message', label: 'Message' }, +]; + +export const addCustomerActionOutputSchema: OutputSchema = { + fields: [ + { key: 'customerId', label: 'Customer ID', format: 'number' }, + { key: 'secret', label: 'Customer Secret' }, + { key: 'portalLink', label: 'Customer Portal Link', format: 'url' }, + ...envelopeFields, + ], +}; + +export const createKeyActionOutputSchema: OutputSchema = { + fields: [ + { key: 'key', label: 'License Key' }, + { + key: 'keys', + label: 'License Keys', + labelKey: 'key', + listItems: [ + { key: 'key', label: 'License Key' }, + { key: 'result', label: 'Result Code', format: 'number' }, + { key: 'message', label: 'Message' }, + ], + }, + ...envelopeFields, + ], +}; + +export const blockKeyActionOutputSchema: OutputSchema = { + fields: envelopeFields, +}; + +export const extendLicenseActionOutputSchema: OutputSchema = { + fields: envelopeFields, +}; + +export const newApiEventTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Event ID', format: 'number' }, + { key: 'productId', label: 'Product ID', format: 'number' }, + { key: 'key', label: 'License Key' }, + { key: 'ip', label: 'Caller IP' }, + { key: 'time', label: 'Time (Unix Seconds)', format: 'number' }, + { key: 'state', label: 'Event State', format: 'number' }, + { key: 'machineCode', label: 'Machine Code' }, + { key: 'friendlyName', label: 'Friendly Name' }, + { key: 'floatingExpires', label: 'Floating Expires', format: 'number' }, + { key: 'doIntValue', label: 'Data Object Int Value', format: 'number' }, + { key: 'doId', label: 'Data Object ID', format: 'number' }, + ], +}; diff --git a/packages/pieces/community/cryptolens/src/lib/triggers/new-api-event.ts b/packages/pieces/community/cryptolens/src/lib/triggers/new-api-event.ts index 6bc02e2d7999..76351c938984 100644 --- a/packages/pieces/community/cryptolens/src/lib/triggers/new-api-event.ts +++ b/packages/pieces/community/cryptolens/src/lib/triggers/new-api-event.ts @@ -12,17 +12,21 @@ import { } from '@activepieces/pieces-common'; import { makeRequest } from '../common/client'; import { cryptolensAuth } from '../common/auth'; +import { newApiEventTriggerOutputSchema } from '../output-schemas'; import { HttpMethod } from '@activepieces/pieces-common'; -interface ObjectLog { - Id: number; - Created: number; - ResourceType: number; - ResourceAction: number; - AffectedObjectId: number; - ObjectOwnerUserId: number; - PerformedByUserId: string; - Data?: string; +interface WebAPILog { + id: number; + productId: number; + key: string; + ip: string; + time: number; + state: number; + machineCode: string | null; + friendlyName: string; + floatingExpires: number; + doIntValue: number; + doId: number; } const props = { @@ -100,15 +104,15 @@ const polling: Polling< const responseBody: { result: number; message?: string; - Events: ObjectLog[]; + logs: WebAPILog[]; } = response; - if (responseBody.result !== 0 || !responseBody.Events) { + if (responseBody.result !== 0 || !responseBody.logs) { return []; } - return responseBody.Events.map((log) => ({ - epochMilliSeconds: log.Created * 1000, + return responseBody.logs.map((log) => ({ + epochMilliSeconds: log.time * 1000, data: log, })); }, @@ -123,16 +127,20 @@ export const newApiEvent = createTrigger({ aiMetadata: { description: 'Fires when a new Web API event is logged in Cryptolens, such as a license activation, deactivation, validation, or key creation. Polls the audit log on an interval and can optionally be scoped to a specific product, a specific key, or particular event-state codes.', }, + outputSchema: newApiEventTriggerOutputSchema, props, sampleData: { - Id: 1, - Created: 1426545812, - ResourceType: 3, - ResourceAction: 1, - AffectedObjectId: 12345, - ObjectOwnerUserId: 999, - PerformedByUserId: 'user123', - Data: '{"key": "value"}', + id: 2280434406, + productId: 12345, + key: 'AAAAA-BBBBB-CCCCC-DDDDD', + ip: '203.0.113.10', + time: 1788259071, + state: 3010, + machineCode: null, + friendlyName: '', + floatingExpires: 0, + doIntValue: 0, + doId: 0, }, type: TriggerStrategy.POLLING, async test(context) { diff --git a/packages/pieces/community/front/package.json b/packages/pieces/community/front/package.json index 432e74b947e2..a47c732a6cca 100644 --- a/packages/pieces/community/front/package.json +++ b/packages/pieces/community/front/package.json @@ -1,21 +1,24 @@ { "name": "@activepieces/piece-front", - "version": "0.1.6", + "version": "0.2.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { + "@activepieces/core-piece-types": "workspace:*", + "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", - "@activepieces/core-piece-types": "workspace:*", - "@activepieces/core-utils": "workspace:*" + "form-data": "4.0.6" }, "devDependencies": { - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "vitest": "3.2.6" }, "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" } } diff --git a/packages/pieces/community/front/src/i18n/translation.json b/packages/pieces/community/front/src/i18n/translation.json index 7f3e56e79aba..1da04e9b01ed 100644 --- a/packages/pieces/community/front/src/i18n/translation.json +++ b/packages/pieces/community/front/src/i18n/translation.json @@ -117,7 +117,7 @@ "List of BCC recipient handles.": "List of BCC recipient handles.", "The subject of the draft.": "The subject of the draft.", "The body of the draft message.": "The body of the draft message.", - "List of attachment URLs.": "List of attachment URLs.", + "Files to attach. Each entry takes a URL, a base64 data URI, or a file from an earlier step. Front allows 25 MB across all attachments on one message.": "Files to attach. Each entry takes a URL, a base64 data URI, or a file from an earlier step. Front allows 25 MB across all attachments on one message.", "Mode of the draft reply": "Mode of the draft reply", "The ID of the signature to use for the draft reply (if applicable).": "The ID of the signature to use for the draft reply (if applicable).", "Whether to append the default signature to the draft reply (if applicable).": "Whether to append the default signature to the draft reply (if applicable).", diff --git a/packages/pieces/community/front/src/lib/actions/create-draft-reply.ts b/packages/pieces/community/front/src/lib/actions/create-draft-reply.ts index 0949321c6e13..17b227e98eb0 100644 --- a/packages/pieces/community/front/src/lib/actions/create-draft-reply.ts +++ b/packages/pieces/community/front/src/lib/actions/create-draft-reply.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { frontAuth } from '../common/auth'; -import { makeRequest } from '../common/client'; +import { makeMultipartRequest, makeRequest } from '../common/client'; +import { frontAttachments } from '../common/attachments'; import { HttpMethod } from '@activepieces/pieces-common'; import { channelIdDropdown, @@ -49,11 +50,7 @@ export const createDraftReply = createAction({ description: 'List of BCC recipient handles.', required: false, }), - attachments: Property.Array({ - displayName: 'Attachments', - description: 'List of attachment URLs.', - required: false, - }), + attachments: frontAttachments.property, mode: Property.StaticDropdown({ displayName: 'Mode', description: 'Mode of the draft reply', @@ -103,12 +100,21 @@ export const createDraftReply = createAction({ if (to) requestBody['to'] = to; if (cc) requestBody['cc'] = cc; if (bcc) requestBody['bcc'] = bcc; - if (attachments) requestBody['attachments'] = attachments; if (mode) requestBody['mode'] = mode; if (signature_id) requestBody['signature_id'] = signature_id; if (should_add_default_signature !== undefined) requestBody['should_add_default_signature'] = should_add_default_signature; + const files = frontAttachments.resolve(attachments); + if (files.length > 0) { + return await makeMultipartRequest({ + auth, + method: HttpMethod.POST, + path: path, + form: frontAttachments.buildBody({ fields: requestBody, files }), + }); + } + return await makeRequest(auth, HttpMethod.POST, path, requestBody); }, }); diff --git a/packages/pieces/community/front/src/lib/actions/create-draft.ts b/packages/pieces/community/front/src/lib/actions/create-draft.ts index 59b3e9328e6c..16c6dd4a47f4 100644 --- a/packages/pieces/community/front/src/lib/actions/create-draft.ts +++ b/packages/pieces/community/front/src/lib/actions/create-draft.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { frontAuth } from '../common/auth'; -import { makeRequest } from '../common/client'; +import { makeMultipartRequest, makeRequest } from '../common/client'; +import { frontAttachments } from '../common/attachments'; import { HttpMethod } from '@activepieces/pieces-common'; import { channelIdDropdown } from '../common/dropdown'; @@ -42,11 +43,7 @@ export const createDraft = createAction({ description: 'The body of the draft message.', required: true, }), - attachments: Property.Array({ - displayName: 'Attachments', - description: 'List of attachment URLs.', - required: false, - }), + attachments: frontAttachments.property, mode: Property.StaticDropdown({ displayName: 'Mode', description: 'Mode of the draft reply', @@ -94,13 +91,22 @@ export const createDraft = createAction({ if (cc) requestBody['cc'] = cc; if (bcc) requestBody['bcc'] = bcc; if (subject) requestBody['subject'] = subject; - if (attachments) requestBody['attachments'] = attachments; if (mode) requestBody['mode'] = mode; if (signature_id) requestBody['signature_id'] = signature_id; if (should_add_default_signature !== undefined) requestBody['should_add_default_signature'] = should_add_default_signature; + const files = frontAttachments.resolve(attachments); + if (files.length > 0) { + return await makeMultipartRequest({ + auth, + method: HttpMethod.POST, + path: `/channels/${channel_id}/drafts`, + form: frontAttachments.buildBody({ fields: requestBody, files }), + }); + } + return await makeRequest( auth, HttpMethod.POST, diff --git a/packages/pieces/community/front/src/lib/actions/send-message.ts b/packages/pieces/community/front/src/lib/actions/send-message.ts index 2ba6728b64d4..9c8a6cb6274c 100644 --- a/packages/pieces/community/front/src/lib/actions/send-message.ts +++ b/packages/pieces/community/front/src/lib/actions/send-message.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { frontAuth } from '../common/auth'; -import { makeRequest } from '../common/client'; +import { makeMultipartRequest, makeRequest } from '../common/client'; +import { frontAttachments } from '../common/attachments'; import { HttpMethod } from '@activepieces/pieces-common'; import { channelIdDropdown, tagIdsDropdown } from '../common/dropdown'; @@ -43,11 +44,7 @@ export const sendMessage = createAction({ description: 'The body of the message.', required: true, }), - attachments: Property.Array({ - displayName: 'Attachments', - description: 'List of attachment URLs.', - required: false, - }), + attachments: frontAttachments.property, tag_ids: tagIdsDropdown, }, async run({ auth, propsValue }) { @@ -61,9 +58,18 @@ export const sendMessage = createAction({ if (cc) requestBody['cc'] = cc; if (bcc) requestBody['bcc'] = bcc; if (subject) requestBody['subject'] = subject; - if (attachments) requestBody['attachments'] = attachments; if (tag_ids) requestBody['tag_ids'] = tag_ids; + const files = frontAttachments.resolve(attachments); + if (files.length > 0) { + return await makeMultipartRequest({ + auth, + method: HttpMethod.POST, + path: `/channels/${channel_id}/messages`, + form: frontAttachments.buildBody({ fields: requestBody, files }), + }); + } + return await makeRequest( auth, HttpMethod.POST, diff --git a/packages/pieces/community/front/src/lib/actions/send-reply.ts b/packages/pieces/community/front/src/lib/actions/send-reply.ts index d5b60ca28956..f1502b452f26 100644 --- a/packages/pieces/community/front/src/lib/actions/send-reply.ts +++ b/packages/pieces/community/front/src/lib/actions/send-reply.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { frontAuth } from '../common/auth'; -import { makeRequest } from '../common/client'; +import { makeMultipartRequest, makeRequest } from '../common/client'; +import { frontAttachments } from '../common/attachments'; import { HttpMethod } from '@activepieces/pieces-common'; import { channelIdDropdown, @@ -48,11 +49,7 @@ export const sendReply = createAction({ required: false, }), channel_id: channelIdDropdown, - attachments: Property.Array({ - displayName: 'Attachments', - description: 'List of attachment URLs.', - required: false, - }), + attachments: frontAttachments.property, }, async run({ auth, propsValue }) { const { @@ -75,7 +72,16 @@ export const sendReply = createAction({ if (cc) requestBody['cc'] = cc; if (bcc) requestBody['bcc'] = bcc; if (channel_id) requestBody['channel_id'] = channel_id; - if (attachments) requestBody['attachments'] = attachments; + + const files = frontAttachments.resolve(attachments); + if (files.length > 0) { + return await makeMultipartRequest({ + auth, + method: HttpMethod.POST, + path: path, + form: frontAttachments.buildBody({ fields: requestBody, files }), + }); + } return await makeRequest(auth, HttpMethod.POST, path, requestBody); }, diff --git a/packages/pieces/community/front/src/lib/common/attachments.ts b/packages/pieces/community/front/src/lib/common/attachments.ts new file mode 100644 index 000000000000..18739026051a --- /dev/null +++ b/packages/pieces/community/front/src/lib/common/attachments.ts @@ -0,0 +1,90 @@ +import { ApFile, Property } from '@activepieces/pieces-framework'; +import FormData from 'form-data'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isApFile(value: unknown): value is ApFile { + return ( + isRecord(value) && + typeof value['filename'] === 'string' && + Buffer.isBuffer(value['data']) + ); +} + +function appendField({ form, name, value }: AppendFieldParams): void { + if (value === null || value === undefined || value === '') { + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => + appendField({ form, name: `${name}[${index}]`, value: entry }) + ); + return; + } + if (isRecord(value)) { + for (const [key, nested] of Object.entries(value)) { + appendField({ form, name: `${name}[${key}]`, value: nested }); + } + return; + } + form.append(name, String(value)); +} + +function resolve(attachments: unknown): ApFile[] { + if (!Array.isArray(attachments)) { + return []; + } + return attachments.map((entry, index) => { + const file = isRecord(entry) ? entry['file'] : undefined; + if (!isApFile(file)) { + throw new Error( + `Attachment ${ + index + 1 + } could not be read. Check that the file still exists and that any URL is reachable.` + ); + } + return file; + }); +} + +function buildBody({ fields, files }: BuildBodyParams): FormData { + const form = new FormData(); + for (const [name, value] of Object.entries(fields)) { + appendField({ form, name, value }); + } + files.forEach((file, index) => { + form.append(`attachments[${index}]`, file.data, file.filename); + }); + return form; +} + +export const frontAttachments = { + property: Property.Array({ + displayName: 'Attachments', + description: + 'Files to attach. Each entry takes a URL, a base64 data URI, or a file from an earlier step. Front allows 25 MB across all attachments on one message.', + required: false, + properties: { + file: Property.File({ + displayName: 'File', + description: 'The file to attach.', + required: true, + }), + }, + }), + resolve, + buildBody, +}; + +type AppendFieldParams = { + form: FormData; + name: string; + value: unknown; +}; + +type BuildBodyParams = { + fields: Record; + files: ApFile[]; +}; diff --git a/packages/pieces/community/front/src/lib/common/client.ts b/packages/pieces/community/front/src/lib/common/client.ts index 2bc85fca6eee..18a5181e2d87 100644 --- a/packages/pieces/community/front/src/lib/common/client.ts +++ b/packages/pieces/community/front/src/lib/common/client.ts @@ -1,5 +1,6 @@ import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import FormData from 'form-data'; import { frontAuth } from './auth'; export const BASE_URL = `https://api2.frontapp.com`; @@ -25,3 +26,27 @@ export async function makeRequest( throw new Error(`Unexpected error: ${error.message || String(error)}`); } } + +export async function makeMultipartRequest({ auth, method, path, form }: MultipartRequestParams) { + try { + const response = await httpClient.sendRequest({ + method, + url: `${BASE_URL}${path}`, + headers: { + ...form.getHeaders(), + Authorization: `Bearer ${auth.secret_text}`, + }, + body: form, + }); + return response.body; + } catch (error) { + throw new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`); + } +} + +type MultipartRequestParams = { + auth: AppConnectionValueForAuthProperty; + method: HttpMethod; + path: string; + form: FormData; +}; diff --git a/packages/pieces/community/front/test/attachments.test.ts b/packages/pieces/community/front/test/attachments.test.ts new file mode 100644 index 000000000000..3dabf69d6cfd --- /dev/null +++ b/packages/pieces/community/front/test/attachments.test.ts @@ -0,0 +1,294 @@ +import { Readable } from 'node:stream'; +import { ApFile, PropertyType } from '@activepieces/pieces-framework'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDraft } from '../src/lib/actions/create-draft'; +import { createDraftReply } from '../src/lib/actions/create-draft-reply'; +import { sendMessage } from '../src/lib/actions/send-message'; +import { sendReply } from '../src/lib/actions/send-reply'; +import { frontAttachments } from '../src/lib/common/attachments'; + +const PDF = Buffer.from('%PDF-1.4 a scanned bill of lading'); +const JPEG = Buffer.from('JPEGDATA'); + +const auth = { secret_text: 'tok_test' }; + +function file(filename = 'bol.pdf', data = PDF): ApFile { + return new ApFile(filename, data, filename.split('.').pop()); +} + +async function readBody(body: unknown): Promise { + if (body instanceof Readable) { + const chunks: Buffer[] = []; + for await (const chunk of body) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('latin1'); + } + return typeof body === 'string' ? body : String(body); +} + +type Captured = { + url: string; + contentType: string; + authorization: string; + body: string; +}; + +let sent: Captured[] = []; + +beforeEach(() => { + sent = []; + vi.stubGlobal('fetch', async (url: string, init: FetchInit) => { + sent.push({ + url: String(url), + contentType: init.headers['content-type'] ?? '', + authorization: init.headers['authorization'] ?? '', + body: await readBody(init.body), + }); + return new Response(JSON.stringify({ id: 'msg_1' }), { + status: 202, + headers: { 'content-type': 'application/json' }, + }); + }); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe('attachments property contract', () => { + it('is an array of files, which is what lets the engine resolve a url before the action runs', () => { + const property = frontAttachments.property; + expect(property.type).toBe(PropertyType.ARRAY); + expect(property.properties?.['file'].type).toBe(PropertyType.FILE); + }); +}); + +describe('resolving the configured entries', () => { + it('treats a missing value as no attachments', () => { + expect(frontAttachments.resolve(undefined)).toEqual([]); + expect(frontAttachments.resolve(null)).toEqual([]); + expect(frontAttachments.resolve([])).toEqual([]); + }); + + it('keeps the configured order', () => { + const files = frontAttachments.resolve([ + { file: file('a.pdf') }, + { file: file('b.pdf') }, + ]); + expect(files.map((f) => f.filename)).toEqual(['a.pdf', 'b.pdf']); + }); + + it('fails loudly when a configured file could not be read', () => { + expect(() => frontAttachments.resolve([{ file: null }])).toThrow( + /Attachment 1 could not be read/ + ); + expect(() => + frontAttachments.resolve([{ file: file() }, {}]) + ).toThrow(/Attachment 2 could not be read/); + }); +}); + +describe('multipart encoding', () => { + it('names array entries by index, the way Front expects', () => { + const form = frontAttachments.buildBody({ + fields: { to: ['a@example.com', 'b@example.com'] }, + files: [], + }); + const body = form.getBuffer().toString('latin1'); + expect(body).toContain('name="to[0]"'); + expect(body).toContain('name="to[1]"'); + }); + + it('addresses a nested object with a second pair of brackets', () => { + const form = frontAttachments.buildBody({ + fields: { options: { tag_ids: ['tag_1'] } }, + files: [], + }); + expect(form.getBuffer().toString('latin1')).toContain( + 'name="options[tag_ids][0]"' + ); + }); + + it('omits an empty value but keeps a false and a zero', () => { + const form = frontAttachments.buildBody({ + fields: { + subject: null, + cc: undefined, + text: '', + should_add_default_signature: false, + count: 0, + }, + files: [], + }); + const body = form.getBuffer().toString('latin1'); + expect(body).not.toContain('name="subject"'); + expect(body).not.toContain('name="cc"'); + expect(body).not.toContain('name="text"'); + expect(body).toContain('name="should_add_default_signature"'); + expect(body).toContain('false'); + expect(body).toContain('name="count"'); + }); + + it('carries the file bytes, its name and a content type derived from it', () => { + const form = frontAttachments.buildBody({ + fields: {}, + files: [file()], + }); + const body = form.getBuffer().toString('latin1'); + expect(body).toContain('name="attachments[0]"; filename="bol.pdf"'); + expect(body).toContain('Content-Type: application/pdf'); + expect(body).toContain(PDF.toString('latin1')); + }); +}); + +describe('sendMessage', () => { + const base = { + channel_id: 'cha_1', + to: ['ops@example.com'], + subject: 'BOL for load 1027576', + body: '

attached

', + }; + + it('sends multipart, with the bytes, when there is an attachment', async () => { + await sendMessage.run(actionContext({ ...base, attachments: [{ file: file() }] })); + + expect(sent).toHaveLength(1); + const [request] = sent; + expect(request.url).toBe('https://api2.frontapp.com/channels/cha_1/messages'); + expect(request.authorization).toBe('Bearer tok_test'); + expect(request.contentType).toMatch(/^multipart\/form-data; boundary=.+/); + expect(request.body).toContain('name="to[0]"'); + expect(request.body).toContain('ops@example.com'); + expect(request.body).toContain('name="attachments[0]"; filename="bol.pdf"'); + expect(request.body).toContain(PDF.toString('latin1')); + }); + + it('attaches more than one file, each under its own index', async () => { + await sendMessage.run( + actionContext({ + ...base, + attachments: [{ file: file() }, { file: file('lumper.jpg', JPEG) }], + }) + ); + + expect(sent[0].body).toContain('name="attachments[0]"; filename="bol.pdf"'); + expect(sent[0].body).toContain('name="attachments[1]"; filename="lumper.jpg"'); + expect(sent[0].body).toContain(JPEG.toString('latin1')); + }); + + it('still sends plain json when there is no attachment', async () => { + await sendMessage.run(actionContext({ ...base, attachments: [] })); + + const [request] = sent; + expect(request.contentType).toContain('application/json'); + expect(JSON.parse(request.body)).toMatchObject({ + channel_id: 'cha_1', + to: ['ops@example.com'], + subject: 'BOL for load 1027576', + }); + expect(request.body).not.toContain('attachments'); + }); + + it('sends nothing at all when a configured attachment could not be read', async () => { + await expect( + sendMessage.run(actionContext({ ...base, attachments: [{ file: null }] })) + ).rejects.toThrow(/could not be read/); + expect(sent).toHaveLength(0); + }); +}); + +describe('sendReply', () => { + it('attaches to a reply on an existing conversation', async () => { + await sendReply.run( + actionContext({ + conversation_id: 'cnv_1', + body: 'here it is', + attachments: [{ file: file() }], + }) + ); + + expect(sent[0].url).toBe( + 'https://api2.frontapp.com/conversations/cnv_1/messages' + ); + expect(sent[0].contentType).toMatch(/^multipart\/form-data; boundary=.+/); + expect(sent[0].body).toContain('name="attachments[0]"; filename="bol.pdf"'); + }); + + it('still sends plain json when there is no attachment', async () => { + await sendReply.run( + actionContext({ conversation_id: 'cnv_1', body: 'no files', attachments: [] }) + ); + expect(sent[0].contentType).toContain('application/json'); + }); +}); + +describe('createDraft', () => { + it('attaches to a new draft', async () => { + await createDraft.run( + actionContext({ + channel_id: 'cha_1', + to: ['ops@example.com'], + body: 'draft body', + mode: 'shared', + attachments: [{ file: file() }], + }) + ); + + expect(sent[0].url).toBe('https://api2.frontapp.com/channels/cha_1/drafts'); + expect(sent[0].contentType).toMatch(/^multipart\/form-data; boundary=.+/); + expect(sent[0].body).toContain('name="attachments[0]"; filename="bol.pdf"'); + expect(sent[0].body).toContain('name="mode"'); + }); + + it('still sends plain json when there is no attachment', async () => { + await createDraft.run( + actionContext({ + channel_id: 'cha_1', + to: ['ops@example.com'], + body: 'draft body', + mode: 'shared', + attachments: [], + }) + ); + expect(sent[0].contentType).toContain('application/json'); + }); +}); + +describe('createDraftReply', () => { + it('attaches to a draft reply on an existing conversation', async () => { + await createDraftReply.run( + actionContext({ + conversation_id: 'cnv_1', + body: 'draft reply', + mode: 'private', + attachments: [{ file: file() }], + }) + ); + + expect(sent[0].url).toBe( + 'https://api2.frontapp.com/conversations/cnv_1/drafts' + ); + expect(sent[0].contentType).toMatch(/^multipart\/form-data; boundary=.+/); + expect(sent[0].body).toContain('name="attachments[0]"; filename="bol.pdf"'); + }); + + it('still sends plain json when there is no attachment', async () => { + await createDraftReply.run( + actionContext({ + conversation_id: 'cnv_1', + body: 'draft reply', + mode: 'private', + attachments: [], + }) + ); + expect(sent[0].contentType).toContain('application/json'); + }); +}); + +function actionContext(propsValue: Record) { + return { auth, propsValue } as unknown as Parameters[0]; +} + +type FetchInit = { + headers: Record; + body: unknown; +}; diff --git a/packages/pieces/community/front/vitest.config.ts b/packages/pieces/community/front/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/community/front/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'), + }, + }, +}) diff --git a/packages/pieces/community/gmail/package.json b/packages/pieces/community/gmail/package.json index 0a82d12cf9e2..33c5d5a2a3a6 100644 --- a/packages/pieces/community/gmail/package.json +++ b/packages/pieces/community/gmail/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-gmail", - "version": "0.13.0", + "version": "0.14.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { @@ -19,12 +19,14 @@ "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" }, "devDependencies": { "@types/mime-types": "2.1.1", "@types/mailparser": "3.4.6", "@types/nodemailer": "7.0.11", - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/gmail/src/i18n/translation.json b/packages/pieces/community/gmail/src/i18n/translation.json index 8c32d18dbc0e..559a142c6df7 100644 --- a/packages/pieces/community/gmail/src/i18n/translation.json +++ b/packages/pieces/community/gmail/src/i18n/translation.json @@ -60,6 +60,7 @@ "Sender Name": "Sender Name", "Sender Email": "Sender Email", "Attachments": "Attachments", + "File": "File", "In reply to": "In reply to", "Create draft": "Create draft", "Message": "Message", diff --git a/packages/pieces/community/gmail/src/lib/actions/request-approval-in-email.ts b/packages/pieces/community/gmail/src/lib/actions/request-approval-in-email.ts index 148a37cc2661..05b1e4e7acc2 100644 --- a/packages/pieces/community/gmail/src/lib/actions/request-approval-in-email.ts +++ b/packages/pieces/community/gmail/src/lib/actions/request-approval-in-email.ts @@ -1,4 +1,4 @@ -import { createAction, Property } from '@activepieces/pieces-framework'; +import { ApFile, createAction, Property } from '@activepieces/pieces-framework'; import { gmailAuth, createGoogleClient, @@ -7,7 +7,8 @@ import { } from '../auth'; import { gmail as googleGmail } from '@googleapis/gmail'; import MailComposer from 'nodemailer/lib/mail-composer'; -import Mail from 'nodemailer/lib/mailer'; +import mime from 'mime-types'; +import Mail, { Attachment } from 'nodemailer/lib/mailer'; import { assertNotNullOrUndefined } from '@activepieces/pieces-framework'; import { ExecutionType } from '@activepieces/pieces-framework'; import { requestApprovalInMailActionOutputSchema } from '../output-schemas'; @@ -22,7 +23,7 @@ export const requestApprovalInEmail = createAction({ audience: 'both', aiMetadata: { description: - 'Sends an email with a single link to a confirmation page where the recipient chooses Approve or Disapprove, then pauses the flow until they respond, resuming with their decision. Use this as a human-in-the-loop gate before proceeding with a sensitive action. The flow blocks indefinitely until a response arrives. Not idempotent: each call sends a new approval email and creates a new wait.', + 'Sends an email, optionally with file attachments, carrying a single link to a confirmation page where the recipient chooses Approve or Disapprove, then pauses the flow until they respond, resuming with their decision. Use this as a human-in-the-loop gate before proceeding with a sensitive action. The flow blocks indefinitely until a response arrives. Not idempotent: each call sends a new approval email and creates a new wait.', idempotent: false, }, props: { @@ -70,6 +71,22 @@ export const requestApprovalInEmail = createAction({ "The address must be listed in your GMail account's settings", required: false, }), + attachments: Property.Array({ + displayName: 'Attachments', + required: false, + properties: { + file: Property.File({ + displayName: 'File', + description: 'File to attach to the approval request email.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Attachment Name', + description: 'In case you want to change the name of the attachment.', + required: false, + }), + }, + }), in_reply_to: Property.ShortText({ displayName: 'In reply to', description: 'Reply to this Message-ID', @@ -113,6 +130,9 @@ export const requestApprovalInEmail = createAction({ context.propsValue['subject'] ).toString('base64'); + const attachments = context.propsValue.attachments as + | { file: ApFile; name: string | undefined }[] + | undefined; const replyTo = context.propsValue['reply_to']?.filter( (email) => email !== '' ); @@ -133,6 +153,24 @@ export const requestApprovalInEmail = createAction({ attachments: [], }; + if (attachments && attachments.length > 0) { + const attachmentOption: Attachment[] = attachments.map( + ({ file, name }) => { + const lookupResult = mime.lookup( + file.extension ? file.extension : '' + ); + return { + filename: name ?? file.filename, + content: file?.base64, + contentType: lookupResult ? lookupResult : undefined, + encoding: 'base64', + }; + } + ); + + mailOptions.attachments = attachmentOption; + } + const senderEmail = context.propsValue.from || (await getUserEmail(context.auth, authClient)); diff --git a/packages/pieces/community/gmail/test/request-approval-attachments-edge-cases.test.ts b/packages/pieces/community/gmail/test/request-approval-attachments-edge-cases.test.ts new file mode 100644 index 000000000000..1ceabc2e3108 --- /dev/null +++ b/packages/pieces/community/gmail/test/request-approval-attachments-edge-cases.test.ts @@ -0,0 +1,197 @@ +/// + +import { createMockActionContext } from '@activepieces/pieces-framework'; +import { simpleParser } from 'mailparser'; + +const sendMock = vi.fn().mockResolvedValue({ data: { id: 'sent-message-id' } }); +const listMock = vi.fn().mockResolvedValue({ data: { messages: [] } }); + +vi.mock('@googleapis/gmail', () => ({ + gmail: () => ({ + users: { messages: { send: sendMock, list: listMock } }, + }), +})); + +vi.mock('../src/lib/auth', () => ({ + gmailAuth: {}, + createGoogleClient: vi.fn().mockResolvedValue({}), + getAccessToken: vi.fn().mockResolvedValue('an-access-token'), + getUserEmail: vi.fn().mockResolvedValue('sender@example.com'), +})); + +import { requestApprovalInEmail } from '../src/lib/actions/request-approval-in-email'; + +const RESUME_URL = 'https://ap.test/resume'; +const BYTES = Buffer.from('%PDF-1.4 edge case attachment'); + +type ApprovalContext = Parameters[0]; + +function buildContext( + propsValue: Record, + overrides: Record = {} +): ApprovalContext { + const context = createMockActionContext({ + propsValue: { + receiver: 'approver@example.com', + subject: 'Please approve', + body: 'Sign off on the attached document.', + ...propsValue, + }, + }); + + return { + ...context, + run: { + ...context.run, + createWaitpoint: async () => ({ id: 'waitpoint-1', resumeUrl: RESUME_URL }), + waitForWaitpoint: () => undefined, + }, + ...overrides, + } as unknown as ApprovalContext; +} + +const attachment = ( + filename: string, + extension: string | undefined, + name?: string +) => ({ + file: { filename, extension, base64: BYTES.toString('base64') }, + name, +}); + +async function sentMail() { + expect(sendMock).toHaveBeenCalledTimes(1); + const raw = sendMock.mock.calls[0][0].requestBody.raw as string; + return await simpleParser( + Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64') + ); +} + +function sentMime() { + const raw = sendMock.mock.calls[0][0].requestBody.raw as string; + return Buffer.from( + raw.replace(/-/g, '+').replace(/_/g, '/'), + 'base64' + ).toString('utf8'); +} + +describe('request approval in email โ€” attachment edge cases', () => { + beforeEach(() => { + sendMock.mockClear(); + listMock.mockClear(); + }); + + test('an empty attachment name still leaves the file named', async () => { + await requestApprovalInEmail.run( + buildContext({ attachments: [attachment('rate-con.pdf', 'pdf', '')] }) + ); + + const mail = await sentMail(); + expect(mail.attachments).toHaveLength(1); + expect(mail.attachments[0].filename).toMatch(/\.pdf$/); + }); + + test('a file with no extension is sent as an opaque binary', async () => { + await requestApprovalInEmail.run( + buildContext({ attachments: [attachment('scan', undefined)] }) + ); + + const mail = await sentMail(); + expect(mail.attachments[0].contentType).toBe('application/octet-stream'); + expect(mail.attachments[0].content.equals(BYTES)).toBe(true); + }); + + test('an unrecognised extension is sent as an opaque binary', async () => { + await requestApprovalInEmail.run( + buildContext({ attachments: [attachment('thing.qqq', 'qqq')] }) + ); + + const mail = await sentMail(); + expect(mail.attachments[0].contentType).toBe('application/octet-stream'); + }); + + test('a non-ASCII attachment name survives the encoding round trip', async () => { + const name = 'ุชุฃูƒูŠุฏ-ุงู„ุณุนุฑ โ€” 10042.pdf'; + await requestApprovalInEmail.run( + buildContext({ attachments: [attachment('tmp.pdf', 'pdf', name)] }) + ); + + const mail = await sentMail(); + expect(mail.attachments[0].filename).toBe(name); + }); + + test('a newline in an attachment name cannot inject a mail header', async () => { + await requestApprovalInEmail.run( + buildContext({ + attachments: [ + attachment( + 'ok.pdf', + 'pdf', + 'invoice.pdf\r\nBcc: attacker@evil.test\r\nX-Injected: yes' + ), + ], + }) + ); + + const mail = await sentMail(); + expect(mail.bcc).toBeUndefined(); + expect(sentMime()).not.toMatch(/^Bcc:/im); + expect(sentMime()).not.toMatch(/^X-Injected:/im); + }); + + test('ten attachments all travel, in the order they were given', async () => { + const many = Array.from({ length: 10 }, (_, i) => + attachment(`doc-${i}.pdf`, 'pdf') + ); + + await requestApprovalInEmail.run(buildContext({ attachments: many })); + + const mail = await sentMail(); + expect(mail.attachments).toHaveLength(10); + expect(mail.attachments.map((a) => a.filename)).toEqual( + many.map((m) => m.file.filename) + ); + }); + + test('an attachment does not disturb the threading headers', async () => { + listMock.mockResolvedValueOnce({ + data: { messages: [{ id: 'm-1', threadId: 'thread-1' }] }, + }); + + await requestApprovalInEmail.run( + buildContext({ + in_reply_to: '', + attachments: [attachment('rate-con.pdf', 'pdf')], + }) + ); + + const mime = sentMime(); + expect(mime).toMatch(/In-Reply-To: /i); + expect(mime).toMatch(/References: /i); + expect((await sentMail()).attachments).toHaveLength(1); + }); + + test('resuming after the approver answers does not send a second email', async () => { + await requestApprovalInEmail.run( + buildContext( + { attachments: [attachment('rate-con.pdf', 'pdf')] }, + { + executionType: 'RESUME', + resumePayload: { queryParams: { action: 'approve' } }, + } + ) + ); + + expect(sendMock).not.toHaveBeenCalled(); + }); + + test('a file that did not resolve fails before any mail is sent', async () => { + await expect( + requestApprovalInEmail.run( + buildContext({ attachments: [{ file: null, name: undefined }] }) + ) + ).rejects.toThrow(); + + expect(sendMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/pieces/community/gmail/test/request-approval-attachments.test.ts b/packages/pieces/community/gmail/test/request-approval-attachments.test.ts new file mode 100644 index 000000000000..ab2535fce10c --- /dev/null +++ b/packages/pieces/community/gmail/test/request-approval-attachments.test.ts @@ -0,0 +1,162 @@ +/// + +import { createMockActionContext } from '@activepieces/pieces-framework'; +import { simpleParser } from 'mailparser'; + +const sendMock = vi.fn().mockResolvedValue({ data: { id: 'sent-message-id' } }); +const listMock = vi.fn().mockResolvedValue({ data: { messages: [] } }); + +vi.mock('@googleapis/gmail', () => ({ + gmail: () => ({ + users: { + messages: { + send: sendMock, + list: listMock, + }, + }, + }), +})); + +vi.mock('../src/lib/auth', () => ({ + gmailAuth: {}, + createGoogleClient: vi.fn().mockResolvedValue({}), + getAccessToken: vi.fn().mockResolvedValue('an-access-token'), + getUserEmail: vi.fn().mockResolvedValue('sender@example.com'), +})); + +import { requestApprovalInEmail } from '../src/lib/actions/request-approval-in-email'; + +const RESUME_URL = 'https://ap.test/resume'; +const PDF_BYTES = Buffer.from('%PDF-1.4 approval attachment'); +const PNG_BYTES = Buffer.from('not really a png'); + +type ApprovalContext = Parameters[0]; + +/** + * The approval action pauses on a waitpoint, which the shared mock context has + * no opinion about, so those two calls are the only additions to it. + */ +function buildContext(propsValue: Record): ApprovalContext { + const context = createMockActionContext({ + propsValue: { + receiver: 'approver@example.com', + subject: 'Please approve', + body: 'Sign off on the attached rate confirmation.', + ...propsValue, + }, + }); + + return { + ...context, + run: { + ...context.run, + createWaitpoint: async () => ({ id: 'waitpoint-1', resumeUrl: RESUME_URL }), + waitForWaitpoint: () => undefined, + }, + } as unknown as ApprovalContext; +} + +function attachment( + filename: string, + extension: string, + bytes: Buffer, + name?: string +) { + return { file: { filename, extension, base64: bytes.toString('base64') }, name }; +} + +/** + * Gmail is handed the whole message as one base64url `raw` field, so read the + * message back out of the call and parse it as the email it actually is. + */ +async function sentMail() { + expect(sendMock).toHaveBeenCalledTimes(1); + const raw = sendMock.mock.calls[0][0].requestBody.raw as string; + const mime = Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + return await simpleParser(mime); +} + +describe('request approval in email', () => { + beforeEach(() => { + sendMock.mockClear(); + listMock.mockClear(); + }); + + test('an attached file arrives with its bytes intact', async () => { + await requestApprovalInEmail.run( + buildContext({ + attachments: [attachment('rate-confirmation.pdf', 'pdf', PDF_BYTES)], + }) + ); + + const mail = await sentMail(); + expect(mail.attachments).toHaveLength(1); + expect(mail.attachments[0].filename).toBe('rate-confirmation.pdf'); + expect(mail.attachments[0].contentType).toBe('application/pdf'); + expect(mail.attachments[0].content.equals(PDF_BYTES)).toBe(true); + }); + + test('the attachment name overrides the name of the file itself', async () => { + await requestApprovalInEmail.run( + buildContext({ + attachments: [ + attachment('tmp-8f21.pdf', 'pdf', PDF_BYTES, 'Load 10042 rate con.pdf'), + ], + }) + ); + + const mail = await sentMail(); + expect(mail.attachments[0].filename).toBe('Load 10042 rate con.pdf'); + }); + + test('the content type comes from the file extension', async () => { + await requestApprovalInEmail.run( + buildContext({ attachments: [attachment('pod.png', 'png', PNG_BYTES)] }) + ); + + const mail = await sentMail(); + expect(mail.attachments[0].contentType).toBe('image/png'); + }); + + test('several attachments all travel', async () => { + await requestApprovalInEmail.run( + buildContext({ + attachments: [ + attachment('bol.pdf', 'pdf', PDF_BYTES), + attachment('pod.png', 'png', PNG_BYTES), + ], + }) + ); + + const mail = await sentMail(); + expect(mail.attachments.map((a) => a.filename)).toEqual(['bol.pdf', 'pod.png']); + }); + + test('the approval link still reaches the recipient alongside an attachment', async () => { + await requestApprovalInEmail.run( + buildContext({ attachments: [attachment('bol.pdf', 'pdf', PDF_BYTES)] }) + ); + + const mail = await sentMail(); + expect(mail.html).toContain(`${RESUME_URL}/confirm`); + expect(mail.html).toContain('Review & Respond'); + expect(mail.subject).toBe('Please approve'); + expect(mail.to?.text).toBe('approver@example.com'); + }); + + test('a flow that never used the field sends the same message as before', async () => { + await requestApprovalInEmail.run(buildContext({})); + + const mail = await sentMail(); + expect(mail.attachments).toHaveLength(0); + expect(mail.html).toContain(`${RESUME_URL}/confirm`); + }); + + test('an empty attachments array sends the same message as before', async () => { + await requestApprovalInEmail.run(buildContext({ attachments: [] })); + + const mail = await sentMail(); + expect(mail.attachments).toHaveLength(0); + expect(mail.html).toContain(`${RESUME_URL}/confirm`); + }); +}); diff --git a/packages/pieces/community/gmail/vitest.config.ts b/packages/pieces/community/gmail/vitest.config.ts new file mode 100644 index 000000000000..f520fc141133 --- /dev/null +++ b/packages/pieces/community/gmail/vitest.config.ts @@ -0,0 +1,18 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/shared': path.resolve(repoRoot, 'packages/core/shared/src/index.ts'), + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'), + }, + }, +}) diff --git a/packages/pieces/community/hugging-face/package.json b/packages/pieces/community/hugging-face/package.json index f0b7b17312e5..f0a51b89cb87 100644 --- a/packages/pieces/community/hugging-face/package.json +++ b/packages/pieces/community/hugging-face/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-hugging-face", - "version": "0.1.7", + "version": "0.1.8", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/hugging-face/src/lib/actions/chat-completion.ts b/packages/pieces/community/hugging-face/src/lib/actions/chat-completion.ts index beb726b2fa2e..0d1d962f298d 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/chat-completion.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/chat-completion.ts @@ -3,6 +3,7 @@ import { InferenceClient } from '@huggingface/inference'; import type { ChatCompletionInput } from '@huggingface/tasks'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { huggingFaceAuth } from '../auth'; +import { chatCompletionOutputSchema } from '../output-schemas'; export const chatCompletion = createAction({ audience: 'both', @@ -17,6 +18,7 @@ export const chatCompletion = createAction({ 'Runs a free-form chat completion against a Hugging Face instruct model (Llama, Mistral, Qwen and similar) and returns the assistant reply, in one of three conversation modes: a single user message, a multi-turn exchange assembled from a conversation-history array, or a template mode that prepends a canned persona system prompt. This is the only open-ended text generator in this piece - prefer text_summarization to condense a document, language_translation to move text between languages, and text_classification to assign labels from a fixed set. A user message is required in single and template modes; not idempotent: each call is a fresh sampled completion and the wording varies between runs.', idempotent: false, }, + outputSchema: chatCompletionOutputSchema, props: { useCase: Property.StaticDropdown({ displayName: 'Use Case', diff --git a/packages/pieces/community/hugging-face/src/lib/actions/create-image.ts b/packages/pieces/community/hugging-face/src/lib/actions/create-image.ts index 310bbe17a67a..891610f82d05 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/create-image.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/create-image.ts @@ -3,6 +3,7 @@ import { InferenceClient } from '@huggingface/inference'; import type { TextToImageInput } from '@huggingface/tasks'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { huggingFaceAuth } from '../auth'; +import { createImageOutputSchema } from '../output-schemas'; export const createImage = createAction({ audience: 'both', @@ -17,6 +18,7 @@ export const createImage = createAction({ 'Generates an image from a text prompt with a Hugging Face text-to-image diffusion model (Stable Diffusion, FLUX and similar), sized by an aspect-ratio preset or custom width and height, and tuned by a quality preset that maps to a denoising-step count. This is the only action here that produces an image; the vision actions image_classification, object_detection, and document_question_answering consume one instead. A non-empty prompt is required; not idempotent: each call renders a new image, and unless an explicit seed is supplied the result differs every run.', idempotent: false, }, + outputSchema: createImageOutputSchema, props: { useCase: Property.StaticDropdown({ displayName: 'Use Case', diff --git a/packages/pieces/community/hugging-face/src/lib/actions/image-classification.ts b/packages/pieces/community/hugging-face/src/lib/actions/image-classification.ts index 400c3922a4eb..c5e72b735f9f 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/image-classification.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/image-classification.ts @@ -8,6 +8,7 @@ import type { } from '@huggingface/tasks'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { huggingFaceAuth } from '../auth'; +import { imageClassificationOutputSchema } from '../output-schemas'; export const imageClassification = createAction({ audience: 'both', @@ -22,6 +23,7 @@ export const imageClassification = createAction({ 'Assigns category labels to a whole image, supplied either as an uploaded file or as a URL the action fetches, in one of two modes: standard mode returns labels from the set the pre-trained model was trained on, while zero-shot mode scores the image against custom candidate categories you supply. Pick it for whole-image tagging or moderation; use object_detection when the positions or counts of individual objects matter, and document_question_answering to read a specific value out of a scanned document. Zero-shot mode requires a non-empty category list; read-only and idempotent, as it only analyses the image.', idempotent: true, }, + outputSchema: imageClassificationOutputSchema, props: { classificationMode: Property.StaticDropdown({ displayName: 'Classification Mode', @@ -445,6 +447,7 @@ export const imageClassification = createAction({ : [String(customCategories).trim()].filter(Boolean); const zeroShotArgs: ZeroShotImageClassificationInput = { + model: model, inputs: imageBlob, parameters: { candidate_labels: candidateLabels, @@ -460,6 +463,7 @@ export const imageClassification = createAction({ ); } else { const standardArgs: ImageClassificationInput = { + model: model, inputs: imageBlob, parameters: { top_k: topK || 5, diff --git a/packages/pieces/community/hugging-face/src/lib/actions/language-translation.ts b/packages/pieces/community/hugging-face/src/lib/actions/language-translation.ts index 9071ccfa9bc7..a2a0ddf4bcc9 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/language-translation.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/language-translation.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { TranslationArgs, InferenceClient } from '@huggingface/inference'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { huggingFaceAuth } from '../auth'; +import { languageTranslationOutputSchema } from '../output-schemas'; export const languageTranslation = createAction({ audience: 'both', @@ -16,6 +17,7 @@ export const languageTranslation = createAction({ 'Translates a block of text with a dedicated Hugging Face machine-translation model, chosen either from the dropdown of Helsinki-NLP opus-mt language pairs or by passing any translation model ID in the custom-model field, which takes precedence over the dropdown. Use it instead of chat_completion whenever the task is purely translation, since these models are narrower and more consistent; the source and target language codes apply only to multilingual models, because a single-pair model already fixes the direction. Read-only and idempotent: it returns the translation and stores nothing.', idempotent: true, }, + outputSchema: languageTranslationOutputSchema, props: { model: Property.Dropdown({ auth: huggingFaceAuth, @@ -229,9 +231,7 @@ export const languageTranslation = createAction({ clean_up_tokenization_spaces?: boolean; src_lang?: string; tgt_lang?: string; - generate_parameters?: { - max_length?: number; - }; + max_length?: number; } = {}; if (cleanUpSpaces !== undefined) { @@ -247,9 +247,7 @@ export const languageTranslation = createAction({ } if (maxLength !== undefined && maxLength > 0) { - parameters.generate_parameters = { - max_length: maxLength, - }; + parameters.max_length = maxLength; } if (Object.keys(parameters).length > 0) { diff --git a/packages/pieces/community/hugging-face/src/lib/actions/object-detection.ts b/packages/pieces/community/hugging-face/src/lib/actions/object-detection.ts index a8669f1f0a78..ea5c381ecace 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/object-detection.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/object-detection.ts @@ -6,6 +6,7 @@ import type { } from '@huggingface/tasks'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { huggingFaceAuth } from '../auth'; +import { objectDetectionOutputSchema } from '../output-schemas'; export const objectDetection = createAction({ audience: 'both', @@ -20,6 +21,7 @@ export const objectDetection = createAction({ 'Locates the individual objects in an uploaded image with a DETR or YOLOS-style detection model, returning each one with a label, a confidence score, and bounding-box coordinates; a filter preset chooses the confidence cut-off (high, balanced, all, or a custom threshold) and a cap limits how many detections come back. Choose it when the positions or counts of objects matter - prefer image_classification for a single whole-image label, and document_question_answering to read a value out of a document scan. Requires an uploaded image file; read-only and idempotent, as it only analyses the image.', idempotent: true, }, + outputSchema: objectDetectionOutputSchema, props: { useCase: Property.StaticDropdown({ displayName: 'Use Case', @@ -295,6 +297,7 @@ export const objectDetection = createAction({ // Build detection arguments const args: ObjectDetectionInput = { + model: model, inputs: imageBlob, parameters: { threshold: actualThreshold, diff --git a/packages/pieces/community/hugging-face/src/lib/actions/text-classification.ts b/packages/pieces/community/hugging-face/src/lib/actions/text-classification.ts index 46a68bcfa36e..dd26364dd0f3 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/text-classification.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/text-classification.ts @@ -6,6 +6,7 @@ import { } from '@huggingface/inference'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { huggingFaceAuth } from '../auth'; +import { textClassificationOutputSchema } from '../output-schemas'; export const textClassification = createAction({ audience: 'both', @@ -20,6 +21,7 @@ export const textClassification = createAction({ 'Scores a block of text against a set of labels and returns the ranked predictions, in one of three modes: zero-shot classifies into custom comma-separated categories you supply, pre-trained uses a curated sentiment, emotion, or topic model with its own fixed label set, and search runs any classification model looked up on the Hugging Face hub. Choose it to sort text into a known label set - use chat_completion for open-ended reasoning about the text, text_summarization to condense it, and image_classification when the input is an image rather than text. Zero-shot mode fails without at least one category; read-only and idempotent, as classifying stores nothing.', idempotent: true, }, + outputSchema: textClassificationOutputSchema, props: { classificationMode: Property.StaticDropdown({ displayName: 'Classification Type', diff --git a/packages/pieces/community/hugging-face/src/lib/actions/text-summarization.ts b/packages/pieces/community/hugging-face/src/lib/actions/text-summarization.ts index 22fa5e2a4f33..13ba67996bed 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/text-summarization.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/text-summarization.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { SummarizationArgs, InferenceClient } from '@huggingface/inference'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { huggingFaceAuth } from '../auth'; +import { textSummarizationOutputSchema } from '../output-schemas'; export const textSummarization = createAction({ audience: 'both', @@ -16,6 +17,7 @@ export const textSummarization = createAction({ 'Condenses one long block of text into a shorter abstractive summary with a Hugging Face summarization model such as BART or Pegasus, targeting either a brief, medium, or detailed length preset or explicit min and max token counts. Choose it over chat_completion when the task is purely summarization, since it decodes greedily for stable output; use text_classification to label the text instead, or language_translation to change its language. Works best on inputs of roughly 512 to 1024 tokens and needs a truncation strategy for anything longer; read-only and idempotent, as it stores nothing.', idempotent: true, }, + outputSchema: textSummarizationOutputSchema, props: { contentType: Property.StaticDropdown({ displayName: 'Content Type', @@ -378,12 +380,8 @@ export const textSummarization = createAction({ | 'longest_first' | 'only_first' | 'only_second'; - generate_parameters?: { - min_length?: number; - max_length?: number; - do_sample?: boolean; - temperature?: number; - }; + min_length?: number; + max_length?: number; } = {}; if (cleanUpSpaces !== undefined) { @@ -400,13 +398,8 @@ export const textSummarization = createAction({ parameters.truncation = truncationStrategy; } - // Add generation parameters - parameters.generate_parameters = { - min_length: minLength, - max_length: maxLength, - do_sample: false, // Use greedy decoding for consistent summaries - temperature: 0.7, // Slight randomness for more natural summaries - }; + parameters.min_length = minLength; + parameters.max_length = maxLength; if (Object.keys(parameters).length > 0) { args.parameters = parameters; diff --git a/packages/pieces/community/hugging-face/src/lib/output-schemas.ts b/packages/pieces/community/hugging-face/src/lib/output-schemas.ts new file mode 100644 index 000000000000..d76154dd6c07 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/output-schemas.ts @@ -0,0 +1,412 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; + +const rawResultField = (key: string): OutputSchemaField => ({ + key, + label: 'Raw Provider Response', + description: 'The untouched response from the model provider. Its shape varies by model.', +}); + +const scoredLabelItems: OutputSchemaField[] = [ + { key: 'label', label: 'Label' }, + { key: 'score', label: 'Score', format: 'number' }, +]; + +export const languageTranslationOutputSchema: OutputSchema = { + fields: [ + { key: 'translatedText', label: 'Translated Text' }, + { key: 'originalText', label: 'Original Text' }, + { key: 'model', label: 'Model' }, + { key: 'sourceLanguage', label: 'Source Language' }, + { key: 'targetLanguage', label: 'Target Language' }, + { + key: 'parameters', + label: 'Parameters Sent', + children: [ + { key: 'clean_up_tokenization_spaces', label: 'Clean Up Spaces', format: 'boolean' }, + { key: 'src_lang', label: 'Source Language Code' }, + { key: 'tgt_lang', label: 'Target Language Code' }, + { + key: 'max_length', + label: 'Max Length', + format: 'number', + description: 'Only present when Max Translation Length is set.', + }, + ], + }, + rawResultField('rawResult'), + ], +}; + +export const textSummarizationOutputSchema: OutputSchema = { + fields: [ + { key: 'summary', label: 'Summary' }, + { key: 'originalText', label: 'Original Text' }, + { + key: 'statistics', + label: 'Statistics', + children: [ + { key: 'originalLength', label: 'Original Length', format: 'number' }, + { key: 'originalWords', label: 'Original Words', format: 'number' }, + { key: 'summaryLength', label: 'Summary Length', format: 'number' }, + { key: 'summaryWords', label: 'Summary Words', format: 'number' }, + { key: 'compressionRatio', label: 'Compression Ratio' }, + { key: 'lengthCategory', label: 'Length Category' }, + ], + }, + { key: 'model', label: 'Model' }, + { key: 'contentType', label: 'Content Type' }, + { + key: 'businessInsights', + label: 'Insights', + children: [ + { key: 'readingTimeSaved', label: 'Reading Time Saved' }, + { key: 'useCase', label: 'Use Case' }, + { key: 'qualityTips', label: 'Quality Tips' }, + ], + }, + rawResultField('rawResult'), + ], +}; + +export const textClassificationOutputSchema: OutputSchema = { + fields: [ + { + key: 'predictions', + label: 'Predictions', + labelKey: 'label', + listItems: scoredLabelItems, + }, + { key: 'topPrediction', label: 'Top Prediction', children: scoredLabelItems }, + { key: 'text', label: 'Text' }, + { key: 'model', label: 'Model' }, + { key: 'classificationMode', label: 'Classification Mode' }, + { + key: 'customCategories', + label: 'Custom Categories', + description: 'Only present in zero-shot mode.', + }, + { key: 'confidenceThreshold', label: 'Confidence Threshold', format: 'number' }, + { + key: 'highConfidencePredictions', + label: 'High Confidence Predictions', + labelKey: 'label', + listItems: scoredLabelItems, + }, + rawResultField('rawResult'), + ], +}; + +export const chatCompletionOutputSchema: OutputSchema = { + fields: [ + { key: 'response', label: 'Response' }, + { + key: 'conversation', + label: 'Conversation', + children: [ + { key: 'userMessage', label: 'User Message' }, + { key: 'assistantMessage', label: 'Assistant Message' }, + { + key: 'fullConversation', + label: 'Full Conversation', + labelKey: 'role', + listItems: [ + { key: 'role', label: 'Role' }, + { key: 'content', label: 'Content' }, + ], + }, + ], + }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'model', label: 'Model' }, + { key: 'useCase', label: 'Use Case' }, + { key: 'conversationMode', label: 'Conversation Mode' }, + { key: 'template', label: 'Template' }, + { key: 'finishReason', label: 'Finish Reason' }, + ], + }, + { + key: 'metrics', + label: 'Metrics', + children: [ + { key: 'userMessageLength', label: 'User Message Length', format: 'number' }, + { key: 'responseLength', label: 'Response Length', format: 'number' }, + { key: 'tokensUsed', label: 'Tokens Used', format: 'number' }, + { key: 'promptTokens', label: 'Prompt Tokens', format: 'number' }, + { key: 'completionTokens', label: 'Completion Tokens', format: 'number' }, + { key: 'estimatedCost', label: 'Estimated Cost' }, + ], + }, + { + key: 'businessInsights', + label: 'Insights', + children: [ + { key: 'useCase', label: 'Use Case' }, + { key: 'qualityTips', label: 'Quality Tips' }, + { key: 'nextSteps', label: 'Next Steps' }, + ], + }, + rawResultField('rawResult'), + ], +}; + +export const createImageOutputSchema: OutputSchema = { + fields: [ + { + key: 'image', + label: 'Image (Base64)', + description: 'The raw base64 payload, without a data URI prefix.', + }, + { + key: 'imageData', + label: 'Image Data', + children: [ + { key: 'format', label: 'Format' }, + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + { key: 'sizeKB', label: 'Size (KB)', format: 'number' }, + { key: 'base64', label: 'Data URI', format: 'image' }, + ], + }, + { + key: 'generation', + label: 'Generation', + children: [ + { key: 'prompt', label: 'Prompt' }, + { key: 'negativePrompt', label: 'Negative Prompt' }, + { key: 'model', label: 'Model' }, + { key: 'useCase', label: 'Use Case' }, + ], + }, + { + key: 'parameters', + label: 'Parameters', + children: [ + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + { key: 'aspectRatio', label: 'Aspect Ratio' }, + { key: 'guidanceScale', label: 'Guidance Scale', format: 'number' }, + { key: 'inferenceSteps', label: 'Inference Steps', format: 'number' }, + { key: 'scheduler', label: 'Scheduler' }, + { key: 'seed', label: 'Seed' }, + ], + }, + { + key: 'metrics', + label: 'Metrics', + children: [ + { key: 'generationTimeSeconds', label: 'Generation Time (Seconds)', format: 'number' }, + { key: 'imageSizeKB', label: 'Image Size (KB)', format: 'number' }, + { key: 'resolution', label: 'Resolution' }, + { key: 'qualitySetting', label: 'Quality Setting' }, + { key: 'estimatedCost', label: 'Estimated Cost' }, + ], + }, + { + key: 'businessInsights', + label: 'Insights', + children: [ + { key: 'useCase', label: 'Use Case' }, + { key: 'qualityTips', label: 'Quality Tips' }, + { key: 'nextSteps', label: 'Next Steps' }, + ], + }, + rawResultField('rawResult'), + ], +}; + +export const objectDetectionOutputSchema: OutputSchema = { + fields: [ + { + key: 'detections', + label: 'Detections', + labelKey: 'label', + listItems: [ + { key: 'id', label: 'ID', format: 'number' }, + { key: 'label', label: 'Label' }, + { key: 'confidence', label: 'Confidence', format: 'number' }, + { key: 'confidencePercent', label: 'Confidence (%)', format: 'number' }, + { + key: 'boundingBox', + label: 'Bounding Box', + children: [ + { key: 'xmin', label: 'X Min', format: 'number' }, + { key: 'ymin', label: 'Y Min', format: 'number' }, + { key: 'xmax', label: 'X Max', format: 'number' }, + { key: 'ymax', label: 'Y Max', format: 'number' }, + ], + }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'area', label: 'Area', format: 'number' }, + { + key: 'center', + label: 'Center', + children: [ + { key: 'x', label: 'X', format: 'number' }, + { key: 'y', label: 'Y', format: 'number' }, + ], + }, + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + ], + }, + ], + }, + { + key: 'summary', + label: 'Summary', + children: [ + { key: 'totalObjectsDetected', label: 'Total Objects Detected', format: 'number' }, + { key: 'objectCategories', label: 'Object Categories', format: 'number' }, + { key: 'mostFrequentObject', label: 'Most Frequent Object' }, + { key: 'averageConfidence', label: 'Average Confidence', format: 'number' }, + { key: 'highConfidenceDetections', label: 'High Confidence Detections', format: 'number' }, + ], + }, + { + key: 'technical', + label: 'Technical', + description: 'Present when Output Format is Technical or Comprehensive.', + children: [ + { key: 'model', label: 'Model' }, + { key: 'threshold', label: 'Threshold', format: 'number' }, + { key: 'processingTime', label: 'Processing Time', format: 'number' }, + { key: 'imageFormat', label: 'Image Format' }, + { key: 'detectionCount', label: 'Detection Count', format: 'number' }, + { key: 'truncated', label: 'Truncated', format: 'boolean' }, + ], + }, + { + key: 'analytics', + label: 'Analytics', + description: 'Present when Output Format is Analytics or Comprehensive.', + children: [ + { + key: 'labelDistribution', + label: 'Label Distribution', + dynamicKey: true, + description: 'One entry per detected label, keyed by the label itself.', + }, + { + key: 'confidenceStatistics', + label: 'Confidence Statistics', + children: [ + { key: 'average', label: 'Average', format: 'number' }, + { key: 'maximum', label: 'Maximum', format: 'number' }, + { key: 'minimum', label: 'Minimum', format: 'number' }, + { key: 'standardDeviation', label: 'Standard Deviation', format: 'number' }, + ], + }, + { + key: 'qualityMetrics', + label: 'Quality Metrics', + children: [ + { key: 'highQuality', label: 'High Quality', format: 'number' }, + { key: 'mediumQuality', label: 'Medium Quality', format: 'number' }, + { key: 'lowQuality', label: 'Low Quality', format: 'number' }, + ], + }, + ], + }, + { + key: 'detection', + label: 'Detection', + children: [ + { key: 'useCase', label: 'Use Case' }, + { key: 'model', label: 'Model' }, + { key: 'imageFile', label: 'Image File' }, + { key: 'threshold', label: 'Threshold', format: 'number' }, + { key: 'maxDetections', label: 'Max Detections', format: 'number' }, + ], + }, + { + key: 'metrics', + label: 'Metrics', + children: [ + { key: 'detectionTimeSeconds', label: 'Detection Time (Seconds)', format: 'number' }, + { key: 'totalDetections', label: 'Total Detections', format: 'number' }, + { key: 'displayedDetections', label: 'Displayed Detections', format: 'number' }, + { key: 'averageConfidence', label: 'Average Confidence', format: 'number' }, + { key: 'processingCost', label: 'Processing Cost' }, + ], + }, + { + key: 'businessInsights', + label: 'Insights', + children: [ + { key: 'useCase', label: 'Use Case' }, + { key: 'detectionTips', label: 'Detection Tips' }, + { key: 'nextSteps', label: 'Next Steps' }, + ], + }, + rawResultField('rawResults'), + ], +}; + +export const imageClassificationOutputSchema: OutputSchema = { + fields: [ + { + key: 'classifications', + label: 'Classifications', + labelKey: 'label', + listItems: [ + { key: 'rank', label: 'Rank', format: 'number' }, + { key: 'label', label: 'Label' }, + { key: 'confidence', label: 'Confidence', format: 'number' }, + { key: 'confidencePercent', label: 'Confidence (%)', format: 'number' }, + { key: 'category', label: 'Category' }, + { key: 'isHighConfidence', label: 'High Confidence', format: 'boolean' }, + { key: 'isMediumConfidence', label: 'Medium Confidence', format: 'boolean' }, + { key: 'isLowConfidence', label: 'Low Confidence', format: 'boolean' }, + ], + }, + { + key: 'summary', + label: 'Summary', + children: [ + { key: 'topCategory', label: 'Top Category' }, + { key: 'topConfidence', label: 'Top Confidence', format: 'number' }, + { key: 'totalCategories', label: 'Total Categories', format: 'number' }, + { key: 'highConfidenceResults', label: 'High Confidence Results', format: 'number' }, + { key: 'recommendedAction', label: 'Recommended Action' }, + ], + }, + { + key: 'classification', + label: 'Classification', + children: [ + { key: 'mode', label: 'Mode' }, + { key: 'useCase', label: 'Use Case' }, + { key: 'model', label: 'Model' }, + { key: 'topCategory', label: 'Top Category' }, + { key: 'confidence', label: 'Confidence', format: 'number' }, + ], + }, + { + key: 'metrics', + label: 'Metrics', + children: [ + { key: 'processingTimeSeconds', label: 'Processing Time (Seconds)', format: 'number' }, + { key: 'totalResults', label: 'Total Results', format: 'number' }, + { key: 'displayedResults', label: 'Displayed Results', format: 'number' }, + { key: 'averageConfidence', label: 'Average Confidence', format: 'number' }, + { key: 'estimatedCost', label: 'Estimated Cost' }, + ], + }, + { + key: 'businessInsights', + label: 'Insights', + children: [ + { key: 'useCase', label: 'Use Case' }, + { key: 'classificationTips', label: 'Classification Tips' }, + { key: 'nextSteps', label: 'Next Steps' }, + ], + }, + rawResultField('rawResults'), + ], +}; diff --git a/packages/pieces/community/moxie-crm/package.json b/packages/pieces/community/moxie-crm/package.json index 9975c8796211..b2a033e5dc28 100644 --- a/packages/pieces/community/moxie-crm/package.json +++ b/packages/pieces/community/moxie-crm/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-moxie-crm", - "version": "0.1.8", + "version": "0.2.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", @@ -15,6 +16,7 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/moxie-crm/src/index.ts b/packages/pieces/community/moxie-crm/src/index.ts index e1629efa9d0d..84ada198ee10 100644 --- a/packages/pieces/community/moxie-crm/src/index.ts +++ b/packages/pieces/community/moxie-crm/src/index.ts @@ -6,8 +6,16 @@ import { } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { moxieCreateClientAction } from './lib/actions/create-client'; +import { moxieCreateContactAction } from './lib/actions/create-contact'; import { moxieCreateProjectAction } from './lib/actions/create-project'; import { moxieCreateTaskAction } from './lib/actions/create-task'; +import { moxieListClientsAction } from './lib/actions/list-clients'; +import { moxieListInvoiceTemplatesAction } from './lib/actions/list-invoice-templates'; +import { moxieListPipelineStagesAction } from './lib/actions/list-pipeline-stages'; +import { moxieListWorkspaceUsersAction } from './lib/actions/list-workspace-users'; +import { moxieSearchClientsAction } from './lib/actions/search-clients'; +import { moxieSearchContactsAction } from './lib/actions/search-contacts'; +import { moxieSearchProjectsAction } from './lib/actions/search-projects'; import { moxieCRMTriggers } from './lib/triggers'; import { moxieCRMAuth } from './lib/auth'; export const moxieCrm = createPiece({ @@ -21,8 +29,16 @@ export const moxieCrm = createPiece({ categories: [PieceCategory.SALES_AND_CRM], actions: [ moxieCreateClientAction, + moxieCreateContactAction, moxieCreateTaskAction, moxieCreateProjectAction, + moxieListClientsAction, + moxieSearchClientsAction, + moxieSearchContactsAction, + moxieSearchProjectsAction, + moxieListPipelineStagesAction, + moxieListWorkspaceUsersAction, + moxieListInvoiceTemplatesAction, createCustomApiCallAction({ baseUrl: (auth) => (auth?.props.baseUrl ?? ''), auth: moxieCRMAuth, diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/create-client.ts b/packages/pieces/community/moxie-crm/src/lib/actions/create-client.ts index b8ffa8d78c95..05a5161d3b06 100644 --- a/packages/pieces/community/moxie-crm/src/lib/actions/create-client.ts +++ b/packages/pieces/community/moxie-crm/src/lib/actions/create-client.ts @@ -1,6 +1,7 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { makeClient } from '../common'; import { moxieCRMAuth } from '../auth'; +import { createClientActionOutputSchema } from '../output-schemas'; export const moxieCreateClientAction = createAction({ auth: moxieCRMAuth, @@ -13,6 +14,7 @@ export const moxieCreateClientAction = createAction({ description: 'Creates a new client or prospect record in Moxie CRM with contact, address, billing, and rate details. Use when onboarding a new account into the CRM; set Client Type to distinguish a converted Client from a Prospect lead. Not idempotent: each call creates a separate client even if the name matches an existing one.', idempotent: false, }, + outputSchema: createClientActionOutputSchema, props: { name: Property.ShortText({ displayName: 'Name', diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/create-contact.ts b/packages/pieces/community/moxie-crm/src/lib/actions/create-contact.ts new file mode 100644 index 000000000000..fee02c6c6541 --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/actions/create-contact.ts @@ -0,0 +1,81 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { moxieCRMAuth } from '../auth'; +import { createContactActionOutputSchema } from '../output-schemas'; + +export const moxieCreateContactAction = createAction({ + auth: moxieCRMAuth, + name: 'moxie_create_contact', + classification: 'WRITE', + displayName: 'Create a Contact', + description: 'Create a new contact record in moxie CRM.', + audience: 'both', + aiMetadata: { + description: + 'Creates a contact in Moxie CRM, optionally attached to an existing client by name. Use when adding a person to an account, or when a lead needs a named contact. Not idempotent: each call creates a separate contact even if the email matches an existing one.', + idempotent: false, + }, + outputSchema: createContactActionOutputSchema, + props: { + first: Property.ShortText({ + displayName: 'First Name', + required: true, + }), + last: Property.ShortText({ + displayName: 'Last Name', + required: true, + }), + email: Property.ShortText({ + displayName: 'Email', + required: false, + }), + phone: Property.ShortText({ + displayName: 'Phone', + required: false, + }), + clientName: Property.Dropdown({ + auth: moxieCRMAuth, + displayName: 'Client', + description: 'The client this contact belongs to.', + required: false, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Please connect your account first', + }; + } + const client = await makeClient(auth); + const clients = await client.listClients(); + return { + options: clients.map((c) => ({ label: c.name, value: c.name })), + }; + }, + }), + defaultContact: Property.Checkbox({ + displayName: 'Default Contact', + description: 'Make this the primary contact for the client.', + required: false, + }), + invoiceContact: Property.Checkbox({ + displayName: 'Invoice Contact', + description: 'Send invoices for the client to this contact.', + required: false, + }), + portalAccess: Property.Checkbox({ + displayName: 'Portal Access', + description: 'Allow this contact to sign in to the client portal.', + required: false, + }), + notes: Property.LongText({ + displayName: 'Notes', + required: false, + }), + }, + async run({ auth, propsValue }) { + const client = await makeClient(auth); + return await client.createContact(propsValue); + }, +}); diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/create-project.ts b/packages/pieces/community/moxie-crm/src/lib/actions/create-project.ts index 1df083b7f6b3..e8ada73c82ae 100644 --- a/packages/pieces/community/moxie-crm/src/lib/actions/create-project.ts +++ b/packages/pieces/community/moxie-crm/src/lib/actions/create-project.ts @@ -5,6 +5,7 @@ import { } from '@activepieces/pieces-framework'; import { makeClient, reformatDate } from '../common'; import { moxieCRMAuth } from '../auth'; +import { createProjectActionOutputSchema } from '../output-schemas'; export const moxieCreateProjectAction = createAction({ auth: moxieCRMAuth, @@ -17,6 +18,7 @@ export const moxieCreateProjectAction = createAction({ description: 'Creates a new project in Moxie CRM under an existing client, including its fee schedule (hourly, fixed price, retainer, or per item), portal access level, and dates. Use when starting a new engagement for a known client. The Client must already exist and is matched by exact client name. Not idempotent: each call creates a separate project.', idempotent: false, }, + outputSchema: createProjectActionOutputSchema, props: { name: Property.ShortText({ displayName: 'Project Name', diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/create-task.ts b/packages/pieces/community/moxie-crm/src/lib/actions/create-task.ts index 8a4c556a87c9..b6fe801f8c0e 100644 --- a/packages/pieces/community/moxie-crm/src/lib/actions/create-task.ts +++ b/packages/pieces/community/moxie-crm/src/lib/actions/create-task.ts @@ -5,6 +5,7 @@ import { } from '@activepieces/pieces-framework'; import { makeClient, reformatDate } from '../common'; import { moxieCRMAuth } from '../auth'; +import { createTaskActionOutputSchema } from '../output-schemas'; export const moxieCreateTaskAction = createAction({ auth: moxieCRMAuth, @@ -17,6 +18,7 @@ export const moxieCreateTaskAction = createAction({ description: 'Creates a task (deliverable) inside an existing project in Moxie CRM, with status, dates, priority, assignees, subtasks, and custom values. Use when adding work items to a project. Requires an exact-match client name and a project name owned by that client. Not idempotent: each call creates a separate task.', idempotent: false, }, + outputSchema: createTaskActionOutputSchema, props: { name: Property.ShortText({ displayName: 'Name', diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/list-clients.ts b/packages/pieces/community/moxie-crm/src/lib/actions/list-clients.ts new file mode 100644 index 000000000000..1174c5ce9602 --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/actions/list-clients.ts @@ -0,0 +1,24 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { moxieCRMAuth } from '../auth'; +import { listClientsActionOutputSchema } from '../output-schemas'; + +export const moxieListClientsAction = createAction({ + auth: moxieCRMAuth, + name: 'moxie_list_clients', + classification: 'READ', + displayName: 'List Clients', + description: 'Retrieve every client and prospect in the workspace.', + audience: 'both', + aiMetadata: { + description: + 'Returns all clients and prospects in the Moxie workspace. Use to find a client id or name before creating a project, task or invoice against it. Read-only and idempotent.', + idempotent: true, + }, + outputSchema: listClientsActionOutputSchema, + props: {}, + async run({ auth }) { + const client = await makeClient(auth); + return await client.listClients(); + }, +}); diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/list-invoice-templates.ts b/packages/pieces/community/moxie-crm/src/lib/actions/list-invoice-templates.ts new file mode 100644 index 000000000000..f8b04ee22cc1 --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/actions/list-invoice-templates.ts @@ -0,0 +1,22 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { moxieCRMAuth } from '../auth'; + +export const moxieListInvoiceTemplatesAction = createAction({ + auth: moxieCRMAuth, + name: 'moxie_list_invoice_templates', + classification: 'READ', + displayName: 'List Invoice Templates', + description: 'Retrieve the invoice template names of the workspace.', + audience: 'both', + aiMetadata: { + description: + 'Returns the names of the invoice templates configured in the Moxie workspace. Use to pick a valid template name before creating an invoice. Read-only and idempotent.', + idempotent: true, + }, + props: {}, + async run({ auth }) { + const client = await makeClient(auth); + return await client.listInvoiceTemplates(); + }, +}); diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/list-pipeline-stages.ts b/packages/pieces/community/moxie-crm/src/lib/actions/list-pipeline-stages.ts new file mode 100644 index 000000000000..54268e33a83c --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/actions/list-pipeline-stages.ts @@ -0,0 +1,24 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { moxieCRMAuth } from '../auth'; +import { listPipelineStagesActionOutputSchema } from '../output-schemas'; + +export const moxieListPipelineStagesAction = createAction({ + auth: moxieCRMAuth, + name: 'moxie_list_pipeline_stages', + classification: 'READ', + displayName: 'List Pipeline Stages', + description: 'Retrieve the opportunity pipeline stages of the workspace.', + audience: 'both', + aiMetadata: { + description: + 'Returns the Moxie pipeline stages, each with its id, label, colour and stage type (New, InProgress, OnHold, ClosedWon, ClosedLost or Complete). Use to resolve a stage id before creating or moving an opportunity. Read-only and idempotent.', + idempotent: true, + }, + outputSchema: listPipelineStagesActionOutputSchema, + props: {}, + async run({ auth }) { + const client = await makeClient(auth); + return await client.listPipelineStages(); + }, +}); diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/list-workspace-users.ts b/packages/pieces/community/moxie-crm/src/lib/actions/list-workspace-users.ts new file mode 100644 index 000000000000..0e1bbdc9e412 --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/actions/list-workspace-users.ts @@ -0,0 +1,24 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { moxieCRMAuth } from '../auth'; +import { listWorkspaceUsersActionOutputSchema } from '../output-schemas'; + +export const moxieListWorkspaceUsersAction = createAction({ + auth: moxieCRMAuth, + name: 'moxie_list_workspace_users', + classification: 'READ', + displayName: 'List Workspace Users', + description: 'Retrieve the users of the workspace and their access.', + audience: 'both', + aiMetadata: { + description: + 'Returns the users in the Moxie workspace with their user type, contact details and project and feature access. Use to resolve the email address of an assignee before creating a task. Read-only and idempotent.', + idempotent: true, + }, + outputSchema: listWorkspaceUsersActionOutputSchema, + props: {}, + async run({ auth }) { + const client = await makeClient(auth); + return await client.listWorkspaceUsers(); + }, +}); diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/search-clients.ts b/packages/pieces/community/moxie-crm/src/lib/actions/search-clients.ts new file mode 100644 index 000000000000..6d9afae32268 --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/actions/search-clients.ts @@ -0,0 +1,31 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { moxieCRMAuth } from '../auth'; +import { searchClientsActionOutputSchema } from '../output-schemas'; + +export const moxieSearchClientsAction = createAction({ + auth: moxieCRMAuth, + name: 'moxie_search_clients', + classification: 'READ', + displayName: 'Search Clients', + description: 'Find clients by name, contact email or contact full name.', + audience: 'both', + aiMetadata: { + description: + 'Searches Moxie clients by client name (starts with), contact email (starts with) or contact full name (contains), and returns the matching client records with their address, billing terms and contacts. Returns an empty list when nothing matches. Read-only and idempotent.', + idempotent: true, + }, + outputSchema: searchClientsActionOutputSchema, + props: { + query: Property.ShortText({ + displayName: 'Query', + description: + 'Matches a client name or contact email that starts with this value, or a contact full name that contains it.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const client = await makeClient(auth); + return await client.searchClients(propsValue.query); + }, +}); diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/search-contacts.ts b/packages/pieces/community/moxie-crm/src/lib/actions/search-contacts.ts new file mode 100644 index 000000000000..6aff716c2de2 --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/actions/search-contacts.ts @@ -0,0 +1,31 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { moxieCRMAuth } from '../auth'; +import { searchContactsActionOutputSchema } from '../output-schemas'; + +export const moxieSearchContactsAction = createAction({ + auth: moxieCRMAuth, + name: 'moxie_search_contacts', + classification: 'READ', + displayName: 'Search Contacts', + description: 'Find contacts by first name, last name or email.', + audience: 'both', + aiMetadata: { + description: + 'Searches Moxie contacts by first name, last name or email and returns the matching contact records, including the client each belongs to. Leave the query empty to list every contact. Read-only and idempotent.', + idempotent: true, + }, + outputSchema: searchContactsActionOutputSchema, + props: { + query: Property.ShortText({ + displayName: 'Query', + description: + 'Matches a contact first name, last name or email. Leave empty to return every contact.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const client = await makeClient(auth); + return await client.searchContacts(propsValue.query); + }, +}); diff --git a/packages/pieces/community/moxie-crm/src/lib/actions/search-projects.ts b/packages/pieces/community/moxie-crm/src/lib/actions/search-projects.ts new file mode 100644 index 000000000000..11576f68a66d --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/actions/search-projects.ts @@ -0,0 +1,30 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { makeClient } from '../common'; +import { moxieCRMAuth } from '../auth'; +import { searchProjectsActionOutputSchema } from '../output-schemas'; + +export const moxieSearchProjectsAction = createAction({ + auth: moxieCRMAuth, + name: 'moxie_search_projects', + classification: 'READ', + displayName: 'Search Projects', + description: 'Find projects belonging to a client.', + audience: 'both', + aiMetadata: { + description: + 'Searches Moxie projects and returns the matches. Use to resolve a project name or id before creating a task or logging time against it. Read-only and idempotent.', + idempotent: true, + }, + outputSchema: searchProjectsActionOutputSchema, + props: { + query: Property.ShortText({ + displayName: 'Query', + description: 'Client name whose projects should be returned.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const client = await makeClient(auth); + return await client.searchProjects(propsValue.query); + }, +}); diff --git a/packages/pieces/community/moxie-crm/src/lib/common/client.ts b/packages/pieces/community/moxie-crm/src/lib/common/client.ts index 5130249b9927..be9be4e8b8c2 100644 --- a/packages/pieces/community/moxie-crm/src/lib/common/client.ts +++ b/packages/pieces/community/moxie-crm/src/lib/common/client.ts @@ -5,6 +5,7 @@ import { HttpResponse, QueryParams, } from '@activepieces/pieces-common'; +import { isNil } from '@activepieces/pieces-framework'; import { ContactCreateRequest, ClientCreateRequest, @@ -101,4 +102,36 @@ export class MoxieCRMClient { ) ).body; } + + async searchClients(query: string) { + return ( + await this.makeRequest( + HttpMethod.GET, + '/action/clients/search', + undefined, + { query } + ) + ).body; + } + + async searchContacts(query?: string) { + return ( + await this.makeRequest( + HttpMethod.GET, + '/action/contacts/search', + undefined, + isNil(query) || query.length === 0 ? undefined : { query } + ) + ).body; + } + + async listPipelineStages() { + return ( + await this.makeRequest(HttpMethod.GET, '/action/pipelineStages/list') + ).body; + } + + async listWorkspaceUsers() { + return (await this.makeRequest(HttpMethod.GET, '/action/users/list')).body; + } } diff --git a/packages/pieces/community/moxie-crm/src/lib/output-schemas.ts b/packages/pieces/community/moxie-crm/src/lib/output-schemas.ts new file mode 100644 index 000000000000..57caf7b0da1c --- /dev/null +++ b/packages/pieces/community/moxie-crm/src/lib/output-schemas.ts @@ -0,0 +1,487 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const contactFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Contact ID' }, + { key: 'firstName', label: 'First Name' }, + { key: 'lastName', label: 'Last Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'phone', label: 'Phone' }, + { key: 'mobile', label: 'Mobile' }, + { key: 'role', label: 'Role' }, + { key: 'clientId', label: 'Client ID' }, + { key: 'defaultContact', label: 'Default Contact', format: 'boolean' }, + { key: 'invoiceContact', label: 'Invoice Contact', format: 'boolean' }, + { key: 'portalAccess', label: 'Portal Access', format: 'boolean' }, + { key: 'notes', label: 'Notes' }, +]; + +const paymentTermsFields: OutputSchema['fields'] = [ + { key: 'paymentDays', label: 'Payment Days', format: 'number' }, + { key: 'latePaymentFee', label: 'Late Payment Fee', format: 'number' }, + { key: 'depositAmount', label: 'Deposit Amount', format: 'number' }, + { key: 'depositType', label: 'Deposit Type' }, + { key: 'hourlyAmount', label: 'Hourly Amount', format: 'number' }, + { key: 'whoPaysCardFees', label: 'Who Pays Card Fees' }, + { key: 'updatedDate', label: 'Updated Date', format: 'datetime' }, + { key: 'updatedBy', label: 'Updated By' }, +]; + +const clientFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Client ID' }, + { key: 'name', label: 'Name' }, + { key: 'clientType', label: 'Client Type' }, + { key: 'initials', label: 'Initials' }, + { key: 'address1', label: 'Address Line 1' }, + { key: 'address2', label: 'Address Line 2' }, + { key: 'city', label: 'City' }, + { key: 'locality', label: 'State or Region' }, + { key: 'postal', label: 'Postal Code' }, + { key: 'country', label: 'Country' }, + { key: 'website', label: 'Website', format: 'url' }, + { key: 'phone', label: 'Phone' }, + { key: 'logo', label: 'Logo', format: 'image' }, + { key: 'color', label: 'Colour' }, + { key: 'taxId', label: 'Tax ID' }, + { key: 'leadSource', label: 'Lead Source' }, + { key: 'archive', label: 'Archived', format: 'boolean' }, + { key: 'hourlyAmount', label: 'Hourly Amount', format: 'number' }, + { key: 'roundingIncrement', label: 'Rounding Increment', format: 'number' }, + { key: 'defaultTaxRate', label: 'Default Tax Rate', format: 'number' }, + { key: 'currency', label: 'Currency' }, + { key: 'payInstructions', label: 'Payment Instructions' }, + { key: 'notes', label: 'Notes' }, + { key: 'stripeClientId', label: 'Stripe Customer ID' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { key: 'lastInvoiceRunDate', label: 'Last Invoice Run Date', format: 'date' }, + { key: 'nextInvoiceRunDate', label: 'Next Invoice Run Date', format: 'date' }, + { key: 'paymentTerms', label: 'Payment Terms', children: paymentTermsFields }, + { + key: 'integrationKeys', + label: 'Integration Keys', + children: [ + { key: 'quickbooksId', label: 'QuickBooks ID' }, + { key: 'xeroId', label: 'Xero ID' }, + ], + }, +]; + +const clientMiniFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Client ID' }, + { key: 'name', label: 'Name' }, + { key: 'clientType', label: 'Client Type' }, + { key: 'initials', label: 'Initials' }, + { key: 'address1', label: 'Address Line 1' }, + { key: 'address2', label: 'Address Line 2' }, + { key: 'city', label: 'City' }, + { key: 'locality', label: 'State or Region' }, + { key: 'postal', label: 'Postal Code' }, + { key: 'country', label: 'Country' }, + { key: 'website', label: 'Website', format: 'url' }, + { key: 'phone', label: 'Phone' }, + { key: 'logo', label: 'Logo', format: 'image' }, + { key: 'color', label: 'Colour' }, + { key: 'taxId', label: 'Tax ID' }, + { key: 'leadSource', label: 'Lead Source' }, + { key: 'archive', label: 'Archived', format: 'boolean' }, + { key: 'hourlyAmount', label: 'Hourly Amount', format: 'number' }, + { key: 'defaultTaxRate', label: 'Default Tax Rate', format: 'number' }, + { key: 'currency', label: 'Currency' }, + { key: 'whoPaysCardFees', label: 'Who Pays Card Fees' }, +]; + +const feeScheduleFields: OutputSchema['fields'] = [ + { key: 'feeType', label: 'Fee Type' }, + { key: 'amount', label: 'Amount', format: 'number' }, + { key: 'estimateMin', label: 'Estimate Minimum', format: 'number' }, + { key: 'estimateMax', label: 'Estimate Maximum', format: 'number' }, + { key: 'taxable', label: 'Taxable', format: 'boolean' }, + { key: 'retainerSchedule', label: 'Retainer Schedule' }, + { key: 'retainerTiming', label: 'Retainer Timing' }, + { key: 'retainerPeriods', label: 'Retainer Periods', format: 'number' }, + { key: 'retainerOverageRate', label: 'Retainer Overage Rate', format: 'number' }, + { key: 'retainerActive', label: 'Retainer Active', format: 'boolean' }, + { key: 'updatedDate', label: 'Updated Date', format: 'datetime' }, + { key: 'updatedBy', label: 'Updated By' }, +]; + +const projectCoreFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Project ID' }, + { key: 'name', label: 'Name' }, + { key: 'clientId', label: 'Client ID' }, + { key: 'projectTypeId', label: 'Project Type ID' }, + { key: 'active', label: 'Active', format: 'boolean' }, + { key: 'startDate', label: 'Start Date', format: 'date' }, + { key: 'dueDate', label: 'Due Date', format: 'date' }, + { key: 'dateCreated', label: 'Created', format: 'datetime' }, + { key: 'hexColor', label: 'Colour' }, + { key: 'portalAccess', label: 'Client Portal Access' }, + { key: 'portalAccessAssignedOnly', label: 'Portal Access Assigned Only', format: 'boolean' }, + { key: 'showTimeWorkedInPortal', label: 'Show Time Worked In Portal', format: 'boolean' }, + { key: 'proposalId', label: 'Proposal ID' }, + { key: 'proposalName', label: 'Proposal Name' }, + { key: 'feeSchedule', label: 'Fee Schedule', children: feeScheduleFields }, +]; + +const taskFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Task ID' }, + { key: 'name', label: 'Name' }, + { key: 'clientId', label: 'Client ID' }, + { key: 'projectId', label: 'Project ID' }, + { key: 'projectTypeId', label: 'Project Type ID' }, + { key: 'statusId', label: 'Status ID' }, + { key: 'description', label: 'Description' }, + { key: 'descriptionFormat', label: 'Description Format' }, + { key: 'type', label: 'Type' }, + { key: 'priority', label: 'Priority', format: 'number' }, + { key: 'taskPriority', label: 'Priority Label' }, + { key: 'startDate', label: 'Start Date', format: 'date' }, + { key: 'dueDate', label: 'Due Date', format: 'date' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { key: 'completed', label: 'Completed', format: 'datetime' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'approvalRequired', label: 'Approval Required', format: 'boolean' }, + { key: 'approvalRequestedAt', label: 'Approval Requested At', format: 'datetime' }, + { key: 'isSubTask', label: 'Is Subtask', format: 'boolean' }, + { key: 'parentTaskId', label: 'Parent Task ID' }, + { key: 'subTaskSort', label: 'Subtask Sort', format: 'number' }, + { key: 'kanbanSort', label: 'Kanban Sort', format: 'number' }, + { key: 'ticketId', label: 'Ticket ID' }, + { key: 'product', label: 'Product' }, + { key: 'quantity', label: 'Quantity', format: 'number' }, + { key: 'invoiceId', label: 'Invoice ID' }, + { key: 'invoiceNumber', label: 'Invoice Number' }, + { key: 'assignedTo', label: 'Assigned To' }, + { key: 'assignedToList', label: 'Assigned User IDs' }, + { + key: 'events', + label: 'Events', + labelKey: 'user', + listItems: [ + { key: 'user', label: 'User' }, + { key: 'events', label: 'Events' }, + { key: 'clientEvent', label: 'Client Event', format: 'boolean' }, + { key: 'timestamp', label: 'Timestamp', format: 'datetime' }, + ], + }, +]; + +const triggerProjectFields: OutputSchema['fields'] = [ + ...projectCoreFields, + { key: 'client', label: 'Client', children: clientMiniFields }, +]; + +const triggerTaskFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Task ID' }, + { key: 'name', label: 'Name' }, + { key: 'clientId', label: 'Client ID' }, + { key: 'projectId', label: 'Project ID' }, + { key: 'projectTypeId', label: 'Project Type ID' }, + { key: 'statusId', label: 'Status ID' }, + { key: 'status', label: 'Status' }, + { key: 'description', label: 'Description' }, + { key: 'descriptionFormat', label: 'Description Format' }, + { key: 'priority', label: 'Priority', format: 'number' }, + { key: 'taskPriority', label: 'Priority Label' }, + { key: 'startDate', label: 'Start Date', format: 'date' }, + { key: 'dueDate', label: 'Due Date', format: 'date' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { key: 'completed', label: 'Completed', format: 'datetime' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'approvalRequired', label: 'Approval Required', format: 'boolean' }, + { key: 'isSubTask', label: 'Is Subtask', format: 'boolean' }, + { key: 'parentTaskId', label: 'Parent Task ID' }, + { key: 'subTaskSort', label: 'Subtask Sort', format: 'number' }, + { key: 'kanbanSort', label: 'Kanban Sort', format: 'number' }, + { key: 'ticketId', label: 'Ticket ID' }, + { key: 'product', label: 'Product' }, + { key: 'quantity', label: 'Quantity', format: 'number' }, + { key: 'invoiceId', label: 'Invoice ID' }, + { key: 'invoiceNumber', label: 'Invoice Number' }, + { key: 'assignedTo', label: 'Assigned To' }, + { key: 'assignedToList', label: 'Assigned User IDs' }, + { + key: 'project', + label: 'Project', + children: [ + { key: 'id', label: 'Project ID' }, + { key: 'name', label: 'Name' }, + { key: 'clientId', label: 'Client ID' }, + { key: 'projectTypeId', label: 'Project Type ID' }, + { key: 'active', label: 'Active', format: 'boolean' }, + { key: 'hexColor', label: 'Colour' }, + ], + }, + { + key: 'client', + label: 'Client', + children: [ + { key: 'id', label: 'Client ID' }, + { key: 'name', label: 'Name' }, + { key: 'initials', label: 'Initials' }, + { key: 'logo', label: 'Logo', format: 'image' }, + { key: 'color', label: 'Colour' }, + ], + }, + { + key: 'events', + label: 'Events', + labelKey: 'user', + listItems: [ + { key: 'user', label: 'User' }, + { key: 'events', label: 'Events' }, + { key: 'clientEvent', label: 'Client Event', format: 'boolean' }, + { key: 'timestamp', label: 'Timestamp', format: 'datetime' }, + ], + }, +]; + +const triggerTimeEntryFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Time Entry ID' }, + { key: 'userId', label: 'User ID', format: 'number' }, + { key: 'userFullName', label: 'User Full Name' }, + { key: 'timerStart', label: 'Timer Start', format: 'datetime' }, + { key: 'timerEnd', label: 'Timer End', format: 'datetime' }, + { key: 'pausedAt', label: 'Paused At', format: 'datetime' }, + { key: 'pausedSeconds', label: 'Paused Seconds', format: 'number' }, + { key: 'duration', label: 'Duration', format: 'duration' }, + { key: 'wasRounded', label: 'Was Rounded', format: 'boolean' }, + { key: 'billable', label: 'Billable', format: 'boolean' }, + { key: 'notes', label: 'Notes' }, + { key: 'clientId', label: 'Client ID' }, + { key: 'clientName', label: 'Client Name' }, + { key: 'projectId', label: 'Project ID' }, + { key: 'projectName', label: 'Project Name' }, + { key: 'deliverableId', label: 'Task ID' }, + { key: 'deliverableName', label: 'Task Name' }, + { key: 'ticketId', label: 'Ticket ID' }, + { key: 'ticketName', label: 'Ticket Name' }, + { key: 'invoiceId', label: 'Invoice ID' }, + { key: 'invoiceNumber', label: 'Invoice Number' }, + { key: 'timestamp', label: 'Timestamp', format: 'datetime' }, + { key: 'timestampUpdated', label: 'Timestamp Updated', format: 'datetime' }, +]; + +const triggerOpportunityFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Opportunity ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'clientId', label: 'Client ID' }, + { key: 'statusId', label: 'Stage ID' }, + { key: 'statusLabel', label: 'Stage' }, + { key: 'value', label: 'Value', format: 'number' }, + { key: 'sentiment', label: 'Sentiment', format: 'number' }, + { key: 'timePeriod', label: 'Time Period' }, + { key: 'periods', label: 'Periods', format: 'number' }, + { key: 'estCloseDate', label: 'Estimated Close Date', format: 'date' }, + { key: 'actualCloseDate', label: 'Actual Close Date', format: 'date' }, + { key: 'wonOn', label: 'Won On', format: 'date' }, + { key: 'archive', label: 'Archived', format: 'boolean' }, + { key: 'kanbanSort', label: 'Kanban Sort', format: 'number' }, + { key: 'created', label: 'Created', format: 'datetime' }, + { + key: 'formData', + label: 'Lead Details', + children: [ + { key: 'firstName', label: 'First Name' }, + { key: 'lastName', label: 'Last Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'phone', label: 'Phone' }, + { key: 'role', label: 'Role' }, + { key: 'businessName', label: 'Business Name' }, + { key: 'website', label: 'Website', format: 'url' }, + { key: 'address1', label: 'Address Line 1' }, + { key: 'address2', label: 'Address Line 2' }, + { key: 'city', label: 'City' }, + { key: 'locality', label: 'State or Region' }, + { key: 'postal', label: 'Postal Code' }, + { key: 'country', label: 'Country' }, + { key: 'sourceUrl', label: 'Source URL', format: 'url' }, + { key: 'leadSource', label: 'Lead Source' }, + ], + }, + { + key: 'comments', + label: 'Comments', + labelKey: 'author', + listItems: [ + { key: 'id', label: 'Comment ID' }, + { key: 'author', label: 'Author' }, + { key: 'comment', label: 'Comment' }, + { key: 'clientComment', label: 'Client Comment', format: 'boolean' }, + { key: 'privateComment', label: 'Private Comment', format: 'boolean' }, + { key: 'edited', label: 'Edited', format: 'boolean' }, + { key: 'timestamp', label: 'Timestamp', format: 'datetime' }, + ], + }, + { + key: 'workflow', + label: 'Workflow', + labelKey: 'itemType', + listItems: [ + { key: 'id', label: 'Item ID' }, + { key: 'itemId', label: 'Referenced ID' }, + { key: 'itemType', label: 'Item Type' }, + { key: 'timestamp', label: 'Timestamp', format: 'datetime' }, + ], + }, +]; + +export const createClientActionOutputSchema: OutputSchema = { + fields: clientFields, +}; + +export const createContactActionOutputSchema: OutputSchema = { + fields: contactFields, +}; + +export const createProjectActionOutputSchema: OutputSchema = { + fields: [ + ...projectCoreFields, + { key: 'description', label: 'Description' }, + { key: 'dateCompleted', label: 'Date Completed', format: 'date' }, + { key: 'proposalVersion', label: 'Proposal Version', format: 'number' }, + { key: 'clientMini', label: 'Client', children: clientMiniFields }, + ], +}; + +export const createTaskActionOutputSchema: OutputSchema = { + fields: taskFields, +}; + +export const searchContactsActionOutputSchema: OutputSchema = { + itemLabel: '{firstName} {lastName}', + fields: [ + { + key: 'contacts', + label: 'Contacts', + value: '', + labelKey: 'firstName', + listItems: contactFields, + }, + ], +}; + +export const listClientsActionOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [ + { + key: 'clients', + label: 'Clients', + value: '', + labelKey: 'name', + listItems: clientFields, + }, + ], +}; + +export const searchClientsActionOutputSchema: OutputSchema = listClientsActionOutputSchema; + +export const searchProjectsActionOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [ + { + key: 'projects', + label: 'Projects', + value: '', + labelKey: 'name', + listItems: [ + ...projectCoreFields, + { + key: 'paymentHistory', + label: 'Payment History', + labelKey: 'invoiceNumberFormatted', + listItems: [ + { key: 'invoiceId', label: 'Invoice ID' }, + { key: 'invoiceNumber', label: 'Invoice Number', format: 'number' }, + { key: 'invoiceNumberFormatted', label: 'Invoice Number Formatted' }, + { key: 'invoiceStatus', label: 'Invoice Status' }, + { key: 'invoiceDate', label: 'Invoice Date', format: 'date' }, + { key: 'dateSent', label: 'Date Sent', format: 'date' }, + { key: 'dateDue', label: 'Date Due', format: 'date' }, + { key: 'amount', label: 'Amount', format: 'number' }, + { key: 'amountDue', label: 'Amount Due', format: 'number' }, + { key: 'currency', label: 'Currency' }, + { key: 'description', label: 'Description' }, + { key: 'itemType', label: 'Item Type' }, + ], + }, + ], + }, + ], +}; + +export const listPipelineStagesActionOutputSchema: OutputSchema = { + itemLabel: '{label}', + fields: [ + { + key: 'stages', + label: 'Pipeline Stages', + value: '', + labelKey: 'label', + listItems: [ + { key: 'id', label: 'Stage ID' }, + { key: 'label', label: 'Label' }, + { key: 'hexColor', label: 'Colour' }, + { key: 'stageType', label: 'Stage Type' }, + ], + }, + ], +}; + +export const listWorkspaceUsersActionOutputSchema: OutputSchema = { + itemLabel: '{user.firstName} {user.lastName}', + fields: [ + { + key: 'users', + label: 'Workspace Users', + value: '', + labelKey: 'userType', + listItems: [ + { key: 'userType', label: 'User Type' }, + { + key: 'user', + label: 'User', + children: [ + { key: 'userId', label: 'User ID', format: 'number' }, + { key: 'firstName', label: 'First Name' }, + { key: 'lastName', label: 'Last Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'phone', label: 'Phone' }, + { key: 'phoneVerified', label: 'Phone Verified', format: 'boolean' }, + { key: 'profilePicture', label: 'Profile Picture', format: 'image' }, + { key: 'uuid', label: 'UUID' }, + ], + }, + ], + }, + ], +}; + +export const clientEventTriggerOutputSchema: OutputSchema = { fields: clientFields }; + +export const projectEventTriggerOutputSchema: OutputSchema = { fields: triggerProjectFields }; + +export const projectTaskEventTriggerOutputSchema: OutputSchema = { fields: triggerTaskFields }; + +export const timeEntryEventTriggerOutputSchema: OutputSchema = { fields: triggerTimeEntryFields }; + +export const opportunityEventTriggerOutputSchema: OutputSchema = { fields: triggerOpportunityFields }; + +export const moxieCRMTriggerOutputSchemas: Record = { + client_created: clientEventTriggerOutputSchema, + client_updated: clientEventTriggerOutputSchema, + client_deleted: clientEventTriggerOutputSchema, + project_created: projectEventTriggerOutputSchema, + project_updated: projectEventTriggerOutputSchema, + project_completed: projectEventTriggerOutputSchema, + task_created: projectTaskEventTriggerOutputSchema, + task_updated: projectTaskEventTriggerOutputSchema, + task_deleted: projectTaskEventTriggerOutputSchema, + client_task_approval: projectTaskEventTriggerOutputSchema, + time_entry_created: timeEntryEventTriggerOutputSchema, + time_entry_updated: timeEntryEventTriggerOutputSchema, + time_entry_deleted: timeEntryEventTriggerOutputSchema, + opportunity_created: opportunityEventTriggerOutputSchema, + opportunity_updated: opportunityEventTriggerOutputSchema, + opportunity_deleted: opportunityEventTriggerOutputSchema, +}; diff --git a/packages/pieces/community/moxie-crm/src/lib/triggers/index.ts b/packages/pieces/community/moxie-crm/src/lib/triggers/index.ts index 8a16a4afc5ba..e099abe962b2 100644 --- a/packages/pieces/community/moxie-crm/src/lib/triggers/index.ts +++ b/packages/pieces/community/moxie-crm/src/lib/triggers/index.ts @@ -26,94 +26,105 @@ export const enum MoxieCRMEventType { const MoxieCRMWebhookSampleData = { PROJECT_EVENT_SAMPLE_DATA: { - id: '6434749e852ec5116d546759', + id: '64b800020000000000000000', accountId: 10016, + projectTypeId: '64b800030000000000000000', sampleData: false, - clientId: '64230b34bd4bbd275c1f1739', + clientId: '64b800010000000000000000', name: 'Design & Development of Website', - description: null, active: true, - startDate: '2023-05-16', - dueDate: '2023-06-06', - dateCreated: '2023-04-10T20:42:06.572Z', + startDate: null, + dueDate: null, + dateCreated: '2026-08-31T10:22:40.856Z', client: { accountId: 10016, sampleData: false, - id: '64230b34bd4bbd275c1f1739', + id: '64b800010000000000000000', clientType: 'Client', - name: 'Moxie', - initials: 'MOX1', - locality: 'CO', + name: 'Moxie, Inc.', + initials: null, + locality: 'OR', country: null, - color: '#3BDBBE', + color: '#78909C', + address1: null, + address2: null, + city: 'Portland', + postal: null, + website: null, + phone: '+18887231235', + s3LogoFile: null, + taxId: null, projects: [], - hourlyAmount: 0, + hourlyAmount: 225, archive: false, - currency: 'AUD', - logo: 'https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://www.withmoxie.com&size=64', - leadSource: 'Google', + currency: null, + logo: null, + leadSource: null, + defaultTaxRate: null, + defaultTaxRuleId: null, + whoPaysCardFees: 'Client', + customValues: [], contact: null, }, leadGenArchived: false, feeSchedule: { - feeType: 'Fixed Price', - amount: 5000, - retainerSchedule: null, + feeType: 'Hourly', + amount: 225, + retainerSchedule: 'Monthly', estimateMax: null, estimateMin: null, retainerStart: null, retainerTiming: 'Advanced', + retainerPeriods: -1, retainerOverageRate: null, taxable: false, fromProposalId: null, fromProposalSignedDate: null, - updatedDate: '2023-04-10T20:42:16.141Z', - updatedBy: 'G. Mina', + updatedDate: null, + updatedBy: null, + retainerActive: true, }, proposalId: null, proposalName: null, - hexColor: '#3BDBBEFF', + hexColor: null, portalAccess: 'Overview', showTimeWorkedInPortal: true, + portalAccessAssignedOnly: false, + projectOwners: [], + customValues: [], + paymentHistory: [], files: [], deliverables: [], }, CLIENT_EVENT_SAMPLE_DATA: { - id: '63c5ea0c840e3207033931b5', + id: '64b800010000000000000000', accountId: 10016, - name: 'Moxie', + name: 'Moxie, Inc.', clientType: 'Client', - initials: 'MOX', - address1: '123 Any Street', - address2: 'Suite 100', - city: 'Anytown', - locality: 'NY', - postal: '12345', - country: 'US', - website: 'www.withmoxie.com', + initials: null, + address1: null, + address2: null, + city: 'Portland', + locality: 'OR', + postal: null, + country: null, + website: null, phone: '+18887231235', - color: '#CE62E9', - logo: 'https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://www.withmoxie.com&size=64', + color: '#78909C', + logo: null, s3LogoFile: null, - taxId: '1212121212', - leadSource: 'PPC', + taxId: null, + leadSource: null, archive: false, leadGenArchived: false, - paymentTerms: { - paymentDays: 7, - latePaymentFee: 5, - depositAmount: 50, - depositType: 'Percentage', - whoPaysCardFees: 'Freelancer', - fromProposalId: '640b60752a524d1c45b6c528', - fromProposalSignedDate: '2023-03-14T14:41:01.908Z', - updatedDate: '2023-04-04T16:17:27.315Z', - updatedBy: 'G. Mina', - }, + paymentTerms: null, payInstructions: null, - hourlyAmount: 100, + hourlyAmount: 225, + defaultTaxRate: null, + defaultTaxRuleId: null, roundingIncrement: 1, - currency: 'USD', + currency: null, + activityInitialized: true, lastInvoiceRunDate: null, nextInvoiceRunDate: null, importRecordId: null, @@ -121,202 +132,83 @@ const MoxieCRMWebhookSampleData = { quickbooksId: null, xeroId: null, }, + customValues: [], files: [], comments: [], - created: '2023-01-17T00:21:32.663Z', + created: '2026-08-31T10:22:33.650Z', sampleData: false, - stripeClientId: 'cus_NFTM1mAFkgtfUI', + stripeClientId: null, + peppolCompliant: false, + mergedIntoClientId: null, + mergedAt: null, + mergedByUserId: null, notes: null, notifyOnCreate: null, - contacts: [ - { - id: '63d431ab3813ca3d0789d2cc', - accountId: 10016, - clientId: '63c5ea0c840e3207033931b5', - clientPortalUserId: -66, - firstName: 'Jeffrey', - lastName: 'Marna', - role: null, - phone: null, - email: 'geoff.mina@withmoxie.com', - mobile: null, - notes: null, - defaultContact: true, - invoiceContact: false, - portalAccess: true, - importRecordId: null, - sampleData: null, - }, - ], + contacts: [], }, PROJECT_TASK_EVENT_SAMPLE_DATA: { - id: '64b3eba1249a076683fe3560', - clientId: '6490580de30ecf51c2c22ffa', - projectId: '649c83111c9cbe6ba1d4cabe', + id: '64b800040000000000000000', + clientId: '64b800010000000000000000', + projectId: '64b800020000000000000000', + projectTypeId: '64b800030000000000000000', + parentTaskId: null, + subTaskSort: 0, project: { - id: '649c83111c9cbe6ba1d4cabe', accountId: 10016, - sampleData: false, - clientId: '6490580de30ecf51c2c22ffa', - name: 'Hourly Project', - description: null, + id: '64b800020000000000000000', + clientId: '64b800010000000000000000', + projectTypeId: '64b800030000000000000000', + name: 'Design & Development of Website', active: true, - startDate: null, - dueDate: null, - dateCreated: '2023-06-28T18:59:29.587Z', - client: null, - leadGenArchived: false, - feeSchedule: { - feeType: 'Hourly', - amount: 150, - retainerSchedule: null, - estimateMax: null, - estimateMin: null, - retainerStart: null, - retainerTiming: 'Advanced', - retainerOverageRate: null, - taxable: false, - fromProposalId: null, - fromProposalSignedDate: null, - updatedDate: null, - updatedBy: null, - }, - proposalId: null, - proposalName: null, - hexColor: '#ffffff00', - portalAccess: 'Overview', - showTimeWorkedInPortal: true, - files: [], - deliverables: [], + hexColor: null, }, client: { accountId: 10016, - sampleData: false, - id: '6490580de30ecf51c2c22ffa', - clientType: 'Client', - name: 'Moxie', + id: '64b800010000000000000000', + name: 'Moxie, Inc.', initials: null, - locality: 'NY', - country: 'US', - color: '#8EA3B8', - projects: [], - hourlyAmount: 150, - archive: false, - currency: 'USD', + s3LogoFile: null, logo: null, - leadSource: null, - contact: { - id: '6490580de30ecf51c2c22ffb', - accountId: 10016, - clientId: '6490580de30ecf51c2c22ffa', - clientPortalUserId: -79, - firstName: 'Geoffrey', - lastName: 'Mina', - role: null, - phone: '+15555551212', - email: 'geoff.mina@withmoxie.com', - mobile: null, - notes: null, - defaultContact: true, - invoiceContact: false, - portalAccess: true, - importRecordId: null, - sampleData: null, - }, - }, - name: 'Another new task to build website and victory', - statusId: '05b26dd1-0668-4dcc-b438-87ac93d4eb15', - status: 'In Progress', - priority: 2, - description: 'This is the description of the task', + color: '#78909C', + }, + name: 'Build the website', + statusId: '3f9a1c62-0b7e-4d51-9a2f-8e6d4b1c7a05', + status: 'Not started', + descriptionFormat: 'Markdown', + priority: 1, + taskPriority: 'Normal', + description: 'This is the description', assignedTo: null, - assignedToList: [16, 222], + assignedToList: [ + 47158, + ], approvalRequired: false, product: null, - quantity: 0, + quantity: null, invoiceId: null, invoiceNumber: null, - customValues: [ - { - fieldId: '4591275b-1c76-4eae-9257-0438fe1d2354', - fieldName: 'Text Input', - value: 'Text input', - }, - { - fieldId: 'a7e49f95-e0f4-416e-9bbf-586d98e1a55e', - fieldName: 'Numeric Input', - value: 123, - }, - { - fieldId: '0a95ed1a-f084-4e13-be02-6065bea93aed', - fieldName: 'Currency Input', - value: 2000, - }, - { - fieldId: '399ac82b-e56e-4499-b4be-db7d3f81f0fc', - fieldName: 'Radio Input', - value: 'Two', - }, - { - fieldId: '006ec49b-68e1-4ebd-9bdc-d18776551616', - fieldName: 'Checkbox Input', - value: 'Three', - }, - { - fieldId: 'd255032b-4f82-4f1b-8b61-4bccc66370c4', - fieldName: 'Phase', - value: 'Phase 1', - }, - { - fieldId: '4a281477-bed5-4f56-9e89-0bcb3758b3f8', - fieldName: 'Shoot Date', - value: '2023-07-07', - }, - { - fieldId: '3fb9c4f2-8093-4315-b2cd-748a3526b497', - fieldName: 'Recurs', - value: 'Yes', - }, - ], - comments: [ - { - id: '81eec0c6-4b6b-46f5-a412-cdc66c8571c8', - author: 'Geoffrey Mina', - authorId: '16', - comment: 'Comments in the task show up here.', - clientComment: false, - edited: false, - privateComment: false, - sendEmail: false, - timestamp: '2023-07-20T10:51:32.086Z', - }, - ], - startDate: '2023-07-01', - dueDate: '2023-07-31', - tasks: [ - { - id: 'e105201a27f34a26a37ef54e6b0f522b', - description: 'One', - complete: true, - }, - { - id: '6c436ea963844783b569639f4c26768e', - description: 'Two', - complete: true, - }, - { - id: 'bf62b9745301466397d09ab9aaa4bd69', - description: 'Three', - complete: false, - }, + ticketId: null, + initialWorkflowComplete: true, + customValues: [], + comments: [], + events: [ { - id: 'dd932850c2094d879c7128cecc468d83', - description: 'Four', - complete: false, + user: 'Jamie Rivera', + events: [ + 'Jamie Rivera Created task', + ], + clientEvent: false, + timestamp: '2026-08-31T10:22:47.654Z', }, ], + startDate: null, + dueDate: null, + created: '2026-08-31T10:22:47.645Z', + completed: null, + tasks: [], archived: false, - kanbanSort: 2, + kanbanSort: 0, + isSubTask: false, }, FORM_EVENT_SAMPLE_DATA: { id: '64b8233d6ce6226305f24b47', @@ -344,27 +236,35 @@ const MoxieCRMWebhookSampleData = { submittedAt: '2023-07-19T17:54:05.257Z', }, TIME_ENTRY_EVENT_SAMPLE_DATA: { - id: '64b857b0b17c7c727001331c', + id: '64b800050000000000000000', accountId: 10016, sampleData: false, userId: 16, - timerStart: '2023-07-19T21:37:35.684Z', - timerEnd: '2023-07-19T21:37:52.431Z', - userFullName: 'Geoffrey Mina', - notes: 'These are some notes', - clientId: '6490580de30ecf51c2c22ffa', - projectId: '649976d658c17d4f29b068ee', - deliverableId: '649976e158c17d4f29b068ef', - clientName: 'Moxie', - projectName: 'Fun project for client', - deliverableName: 'Task 1', - timestamp: null, + timerStart: '2026-08-29T09:00:00.000Z', + timerEnd: '2026-08-29T10:15:00.000Z', + pausedAt: null, + userFullName: 'Jamie Rivera', + notes: 'This is the description', + format: 'Markdown', + clientId: '64b800010000000000000000', + projectId: '64b800020000000000000000', + deliverableId: null, + ticketId: null, + clientName: 'Moxie, Inc.', + projectName: 'Design & Development of Website', + deliverableName: null, + ticketName: null, + timestamp: '2026-08-31T10:22:54.688Z', timestampUpdated: null, + billable: true, + pausedSeconds: 0, invoiceId: null, invoiceNumber: null, importRecordId: null, feeSchedule: null, - duration: 16, + customGroupFieldValue: null, + duration: 4500, + wasRounded: false, }, MEETING_EVENT_SAMPLE_DATA: { id: '64b824df6ce6226305f24b53', @@ -455,156 +355,87 @@ const MoxieCRMWebhookSampleData = { icalUid: '64b824df6ce6226305f24b53@hecticapp.com', }, OPPORTUNITY_EVENT_SAMPLE_DATA: { - id: '642dfde9fd537145d22edbaa', + id: '64b800060000000000000000', accountId: 10016, - clientId: '5f7b3335b50f2a000189217a', - statusId: '30180ce4-ba0a-4b5d-b92c-d2733e11b514', - kanbanSort: 1, - name: 'New Opportunity', + clientId: null, + statusId: '3f9a1c62-0b7e-4d51-9a2f-8e6d4b1c7a05', + kanbanSort: 0, + name: 'Hook SubmitterC / null', description: null, + assignedTo: [], + format: 'Markdown', sentiment: 2, - value: 1500, + value: 0, timePeriod: 'OneTime', periods: 1, - estCloseDate: '2023-07-31', + estCloseDate: null, actualCloseDate: null, formData: { - firstName: 'Geoff', - lastName: 'Mina', - email: 'geoff.mina@withmoxie.com', - phone: '555-555-5554', - role: 'Executive', - businessName: 'Moxie', - website: 'www.withmoxie.com', - address1: '123 Any Stree', - address2: 'Suite 100', - city: 'Boulder', - locality: 'CO', - postal: '80301', - country: 'US', - sourceUrl: 'https://hello.hecticapp.dev/00/hectic-lab/fancy-new-form-v2', + firstName: 'Jamie', + lastName: 'Rivera', + email: 'hello@withmoxie.com', + phone: '+18887231235', + role: null, + businessName: 'Hook Co C', + website: null, + address1: null, + address2: null, + city: null, + locality: null, + postal: null, + country: null, + taxId: null, + sourceUrl: null, opportunityId: null, - templateId: '642356ec35707318aa08bd18', + templateId: null, cardTokenId: null, - leadSource: 'Google', + leadSource: null, + clientId: null, answers: [ { - id: '516e2366-a52f-4d69-8059-d2df8e692f12', - fieldKey: 'Field9', + id: '3f9a1c62-0b7e-4d51-9a2f-8e6d4b1c7a05', + fieldKey: 'q1', fieldType: 'TextInput', - question: 'Enter question text', - answer: 'Blah', - }, - { - id: 'e30eb7c2-2b69-4a88-a973-92321c5c147d', - fieldKey: 'Field16', - fieldType: 'Checkbox', - question: 'Choose an option', - answer: 'Option 1, Option 2', - }, - { - id: 'ba71978a-67df-4335-9a70-7bfad8185788', - fieldKey: 'Field13', - fieldType: 'Radio', - question: 'Choose an option', - answer: 'Option 2', - }, - { - id: '4dd4471d-04c0-4795-8859-5f4d3b0fb283', - fieldKey: 'Field7', - fieldType: 'DateInput', - question: 'Select a date', - answer: '2023-04-06', - }, - { - id: 'a201bb30-48c4-4408-a35e-0ff619a660f9', - fieldKey: 'Field15', - fieldType: 'TextArea', - question: 'Enter question text', - answer: 'Blah', - }, - { - id: '973d4b26-2629-4819-92ed-7f8c73268982', - fieldKey: 'Field8', - fieldType: 'FileInput', - question: 'Upload your file', - answer: '["4BFE0272-D8BC-46CA-A8C8-079BB34B1BA0.jpeg"]', + question: 'What do you need?', + answer: 'Sample notes', }, ], }, archive: false, initialWorkflow: true, - toDos: [ - { - id: '4ed64532451f4421a063a7b61f2016b7', - item: 'Make Phone Call', - complete: false, - dueDate: '2023-07-20', - dateCompleted: null, - relativeDueDate: { - duration: null, - timeUnit: null, - }, - }, - { - id: '404fd07b56494676b2dc3aaee7a3b30f', - item: 'Send Email', - complete: false, - dueDate: '2023-07-23', - dateCompleted: null, - relativeDueDate: { - duration: null, - timeUnit: null, - }, - }, - ], + toDos: [], comments: [ { id: null, - author: 'System', + author: 'Jamie Rivera', authorId: '0', - comment: 'Auto created from form: Fancy New Form V2', + comment: 'Auto created from form: null', + format: 'Markdown', clientComment: false, edited: false, privateComment: false, sendEmail: false, - timestamp: '2023-04-05T23:02:00.618Z', - }, - ], - files: [ - { - fileName: 'Screenshot 2023-07-19 at 6.11.40 PM.png', - fileType: 'PNG', - timestamp: '2023-07-20T10:54:30.759Z', - fileIconUrl: - 'https://struxture-www-assets.s3.us-east-2.amazonaws.com/file-icons/png.png', - }, - { - fileName: 'Screenshot 2023-07-19 at 1.50.59 PM.png', - fileType: 'PNG', - timestamp: '2023-07-20T10:54:30.991Z', - fileIconUrl: - 'https://struxture-www-assets.s3.us-east-2.amazonaws.com/file-icons/png.png', + timestamp: '2026-08-31T10:23:08.787Z', }, ], + files: [], workflow: [ { - id: 'c595dc6c-b7ea-4dfd-ac10-0883320e1eb8', - itemId: '642dfde2fd537145d22edba5', + id: '3f9a1c62-0b7e-4d51-9a2f-8e6d4b1c7a05', + itemId: '64b800070000000000000000', itemType: 'Form', properties: {}, - timestamp: '2023-04-05T23:01:54.206Z', - }, - ], - customValues: [ - { - fieldId: '5e9d7171-1988-431d-a1c8-491e4ae71613', - fieldName: 'Custom Field', - value: 'Custom Field Value', + timestamp: '2026-08-31T10:23:08.786Z', }, ], + customValues: [], history: [], - statusLabel: 'Contract', + importRecordId: null, + sampleData: false, + created: '2026-08-31T10:23:08.786Z', + wonOn: null, + client: null, + statusLabel: 'Inquiry', }, INVOICE_EVENT_SAMPLE_DATA: { id: '64ae5aea99f38e74fc78ae46', diff --git a/packages/pieces/community/moxie-crm/src/lib/triggers/register-trigger.ts b/packages/pieces/community/moxie-crm/src/lib/triggers/register-trigger.ts index 00b57d6abbfd..9864552be05a 100644 --- a/packages/pieces/community/moxie-crm/src/lib/triggers/register-trigger.ts +++ b/packages/pieces/community/moxie-crm/src/lib/triggers/register-trigger.ts @@ -6,6 +6,7 @@ import { } from '@activepieces/pieces-framework'; import { MoxieCRMEventType } from '.'; import { moxieCRMAuth } from '../auth'; +import { moxieCRMTriggerOutputSchemas } from '../output-schemas'; export const moxieCRMRegisterTrigger = ({ name, displayName, @@ -43,6 +44,7 @@ export const moxieCRMRegisterTrigger = ({ `, }), }, + outputSchema: moxieCRMTriggerOutputSchemas[name], type: TriggerStrategy.WEBHOOK, sampleData: sampleData, async onEnable(context) { diff --git a/packages/pieces/community/moxie-crm/test/client.test.ts b/packages/pieces/community/moxie-crm/test/client.test.ts new file mode 100644 index 000000000000..3fd61012043c --- /dev/null +++ b/packages/pieces/community/moxie-crm/test/client.test.ts @@ -0,0 +1,206 @@ +/// + +import { HttpMethod } from '@activepieces/pieces-common'; + +const { sendRequest } = vi.hoisted(() => ({ + sendRequest: vi.fn(), +})); + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@activepieces/pieces-common') + >(); + return { + ...actual, + httpClient: { sendRequest }, + }; +}); + +const { MoxieCRMClient } = await import('../src/lib/common/client'); + +const BASE_URL = 'https://pod01.withmoxie.com/api/public'; +const API_KEY = 'test-api-key'; + +function buildClient(baseUrl: string = BASE_URL) { + return new MoxieCRMClient(baseUrl, API_KEY); +} + +function lastRequest() { + return sendRequest.mock.calls[sendRequest.mock.calls.length - 1][0]; +} + +beforeEach(() => { + sendRequest.mockReset(); + sendRequest.mockResolvedValue({ status: 200, headers: {}, body: [] }); +}); + +describe('MoxieCRMClient base url handling', () => { + test('a base url without a trailing slash builds a single-slash path', async () => { + await buildClient().listWorkspaceUsers(); + + expect(lastRequest().url).toBe( + 'https://pod01.withmoxie.com/api/public/action/users/list' + ); + }); + + test('a trailing slash on the base url does not produce a double slash', async () => { + await buildClient(`${BASE_URL}/`).listWorkspaceUsers(); + + expect(lastRequest().url).toBe( + 'https://pod01.withmoxie.com/api/public/action/users/list' + ); + expect(lastRequest().url).not.toContain('//action'); + }); + + test('every request carries the X-API-KEY header and no bearer token', async () => { + await buildClient().listPipelineStages(); + + expect(lastRequest().headers).toEqual({ 'X-API-KEY': API_KEY }); + expect(lastRequest().headers).not.toHaveProperty('Authorization'); + }); +}); + +describe('MoxieCRMClient read endpoints', () => { + test('listClients calls GET /action/clients/list with no body or query', async () => { + await buildClient().listClients(); + + expect(lastRequest()).toMatchObject({ + method: HttpMethod.GET, + url: `${BASE_URL}/action/clients/list`, + }); + expect(lastRequest().body).toBeUndefined(); + expect(lastRequest().queryParams).toBeUndefined(); + }); + + test('listPipelineStages calls GET /action/pipelineStages/list', async () => { + await buildClient().listPipelineStages(); + + expect(lastRequest().method).toBe(HttpMethod.GET); + expect(lastRequest().url).toBe(`${BASE_URL}/action/pipelineStages/list`); + }); + + test('listWorkspaceUsers calls GET /action/users/list', async () => { + await buildClient().listWorkspaceUsers(); + + expect(lastRequest().method).toBe(HttpMethod.GET); + expect(lastRequest().url).toBe(`${BASE_URL}/action/users/list`); + }); + + test('listInvoiceTemplates calls GET /action/invoiceTemplates/list', async () => { + await buildClient().listInvoiceTemplates(); + + expect(lastRequest().url).toBe( + `${BASE_URL}/action/invoiceTemplates/list` + ); + }); +}); + +describe('MoxieCRMClient search endpoints', () => { + test('searchClients sends the query as a query param, not in the path', async () => { + await buildClient().searchClients('Moxie'); + + expect(lastRequest()).toMatchObject({ + method: HttpMethod.GET, + url: `${BASE_URL}/action/clients/search`, + queryParams: { query: 'Moxie' }, + }); + expect(lastRequest().url).not.toContain('Moxie'); + }); + + test('searchClients passes a query with spaces and ampersands through unencoded', async () => { + await buildClient().searchClients('Ada & Co'); + + expect(lastRequest().queryParams).toEqual({ query: 'Ada & Co' }); + }); + + test('searchProjects sends the query as a query param', async () => { + await buildClient().searchProjects('Moxie'); + + expect(lastRequest()).toMatchObject({ + method: HttpMethod.GET, + url: `${BASE_URL}/action/projects/search`, + queryParams: { query: 'Moxie' }, + }); + }); + + test('searchContacts with a query sends it', async () => { + await buildClient().searchContacts('ada'); + + expect(lastRequest()).toMatchObject({ + url: `${BASE_URL}/action/contacts/search`, + queryParams: { query: 'ada' }, + }); + }); + + test('searchContacts with no query omits queryParams entirely', async () => { + await buildClient().searchContacts(); + + expect(lastRequest().url).toBe(`${BASE_URL}/action/contacts/search`); + expect(lastRequest().queryParams).toBeUndefined(); + }); + + test('searchContacts with an empty query omits queryParams rather than sending query=', async () => { + await buildClient().searchContacts(''); + + expect(lastRequest().queryParams).toBeUndefined(); + }); +}); + +describe('MoxieCRMClient createContact', () => { + test('posts the request body to /action/contacts/create', async () => { + const request = { + first: 'Ada', + last: 'Chen', + email: 'ada@example.com', + clientName: 'Moxie', + defaultContact: true, + }; + + await buildClient().createContact(request); + + expect(lastRequest()).toMatchObject({ + method: HttpMethod.POST, + url: `${BASE_URL}/action/contacts/create`, + body: request, + }); + }); + + test('omitted optional fields are not invented', async () => { + await buildClient().createContact({ first: 'Ada', last: 'Chen' }); + + expect(lastRequest().body).toEqual({ first: 'Ada', last: 'Chen' }); + }); +}); + +describe('MoxieCRMClient response unwrapping', () => { + test('every method returns response.body, not the HttpResponse wrapper', async () => { + const payload = [{ id: 'c1', name: 'Moxie' }]; + sendRequest.mockResolvedValue({ + status: 200, + headers: { 'x-secret': 'do-not-surface' }, + body: payload, + }); + + const client = buildClient(); + + await expect(client.listClients()).resolves.toBe(payload); + await expect(client.searchClients('Moxie')).resolves.toBe(payload); + await expect(client.searchContacts()).resolves.toBe(payload); + await expect(client.listPipelineStages()).resolves.toBe(payload); + await expect(client.listWorkspaceUsers()).resolves.toBe(payload); + }); + + test('an empty result set comes back as an empty array, not undefined', async () => { + sendRequest.mockResolvedValue({ status: 200, headers: {}, body: [] }); + + await expect(buildClient().searchClients('nothing')).resolves.toEqual([]); + }); + + test('a rejected request propagates rather than resolving undefined', async () => { + sendRequest.mockRejectedValue(new Error('429 Too Many Requests')); + + await expect(buildClient().listClients()).rejects.toThrow( + '429 Too Many Requests' + ); + }); +}); diff --git a/packages/pieces/community/moxie-crm/vitest.config.ts b/packages/pieces/community/moxie-crm/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/community/moxie-crm/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'), + }, + }, +}) diff --git a/packages/pieces/core/crypto/package.json b/packages/pieces/core/crypto/package.json index e18eb9b2782c..de733737f090 100644 --- a/packages/pieces/core/crypto/package.json +++ b/packages/pieces/core/crypto/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-crypto", - "version": "0.0.27", + "version": "0.0.28", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/core/crypto/src/lib/actions/openpgp-encrypt.ts b/packages/pieces/core/crypto/src/lib/actions/openpgp-encrypt.ts index 26c685e6e915..a6395323adaf 100644 --- a/packages/pieces/core/crypto/src/lib/actions/openpgp-encrypt.ts +++ b/packages/pieces/core/crypto/src/lib/actions/openpgp-encrypt.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import * as openpgp from 'openpgp'; +import { openpgpEncryptActionOutputSchema } from '../output-schemas'; export const openpgpEncrypt = createAction({ audience: 'both', @@ -8,6 +9,7 @@ export const openpgpEncrypt = createAction({ displayName: 'OpenPGP Encrypt', description: 'Encrypt a file using OpenPGP public key', aiMetadata: { description: 'Encrypts a binary file with an OpenPGP public key in ASCII-armor format and writes out an armored .pgp file. Pick this to protect an attachment before handing it to a recipient who holds the matching private key; this piece offers no decrypt counterpart. Requires a valid armored public key, and an unreadable key is reported as a failed result rather than raising an error; not idempotent: each call derives a fresh random session key, so the same input produces different ciphertext.', idempotent: false }, + outputSchema: openpgpEncryptActionOutputSchema, props: { file: Property.File({ displayName: 'File', diff --git a/packages/pieces/core/crypto/src/lib/output-schemas.ts b/packages/pieces/core/crypto/src/lib/output-schemas.ts new file mode 100644 index 000000000000..0af43f1c2a0d --- /dev/null +++ b/packages/pieces/core/crypto/src/lib/output-schemas.ts @@ -0,0 +1,10 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const openpgpEncryptActionOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'filename', label: 'File Name' }, + { key: 'file', label: 'Encrypted File', format: 'url' }, + { key: 'error', label: 'Error' }, + ], +}; diff --git a/packages/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index b701ac68c502..7568bbd446a6 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -19,6 +19,7 @@ import { appConnectionModule } from './app-connection/app-connection.module' import { platformAppConnectionModule } from './app-connection/platform-app-connection.module' import { authenticationModule } from './authentication/authentication.module' import { otpModule } from './authentication/otp/otp-module' +import { passwordlessAuthModule } from './authentication/passwordless-auth.module' import { canaryRoutingMiddleware } from './core/canary/canary-routing.middleware' import { collaborativeModule } from './core/collaborative/collaborative.module' import { oidcModule } from './core/security/oidc/oidc.module' @@ -92,6 +93,7 @@ import { shutdownTelemetry } from './helper/telemetry.utils' import { knowledgeBaseModule } from './knowledge-base/knowledge-base.module' import { mcpServerModule } from './mcp/mcp-module' import { mcpOAuthApproveController } from './mcp/oauth/code/mcp-oauth-approve.controller' +import { mcpOAuthGrantsController } from './mcp/oauth/token/mcp-oauth-grants.controller' import { communityPiecesModule } from './pieces/community-piece-module' import { startDevPieceWatcher } from './pieces/dev-piece-watcher' import { pieceModule } from './pieces/metadata/piece-metadata-controller' @@ -241,6 +243,7 @@ export const setupApp = async (app: FastifyInstance): Promise = await app.register(humanInputModule) await app.register(mcpServerModule) await app.register(mcpOAuthApproveController) + await app.register(mcpOAuthGrantsController) await app.register(agentsModule) await app.register(platformUserModule) await app.register(alertsModule) @@ -325,6 +328,7 @@ export const setupApp = async (app: FastifyInstance): Promise = await app.register(pieceSetModule) await app.register(otpModule) await app.register(enterpriseLocalAuthnModule) + await app.register(passwordlessAuthModule) await app.register(federatedAuthModule) await app.register(apiKeyModule) await app.register(gitRepoModule) diff --git a/packages/server/api/src/app/authentication/authentication.controller.ts b/packages/server/api/src/app/authentication/authentication.controller.ts index 23222f205f5e..4e3c7863b9cb 100644 --- a/packages/server/api/src/app/authentication/authentication.controller.ts +++ b/packages/server/api/src/app/authentication/authentication.controller.ts @@ -1,10 +1,9 @@ import { isNil } from '@activepieces/core-utils' -import { ApplicationEventName, CompleteSignUpRequest, PrincipalType, RequestEmailCodeRequest, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider, VerifyEmailCodeRequest } from '@activepieces/shared' +import { ApplicationEventName, CompleteSignUpRequest, PrincipalType, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider } from '@activepieces/shared' import { FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' -import { StatusCodes } from 'http-status-codes' import { securityAccess } from '../core/security/authorization/fastify-security' -import { authnRateLimit, emailCodeRateLimit } from '../core/security/rate-limit' +import { authnRateLimit } from '../core/security/rate-limit' import { applicationEvents } from '../helper/application-events' import { networkUtils } from '../helper/network-utils' import { rejectedPromiseHandler } from '../helper/promise-handler' @@ -82,47 +81,6 @@ export const authenticationController: FastifyPluginAsyncZod = async ( return response }) - app.post('/otp/request', RequestEmailCodeRequestOptions, async (request, reply) => { - const platformId = await platformUtils.getPlatformIdForRequest(request) - await passwordlessAuthService(request.log).requestCode({ - email: request.body.email, - platformId: platformId ?? null, - captchaToken: request.body.captchaToken, - remoteIp: clientIp(request), - }) - return reply.code(StatusCodes.NO_CONTENT).send() - }) - - app.post('/otp/verify', VerifyEmailCodeRequestOptions, async (request) => { - const platformId = await platformUtils.getPlatformIdForRequest(request) - const response = await passwordlessAuthService(request.log).verifyCode({ - email: request.body.email, - code: request.body.code, - platformId: platformId ?? null, - }) - - if (!isNil(response.platformId)) { - applicationEvents(request.log).sendUserEvent({ - platformId: response.platformId, - userId: response.id, - projectId: response.projectId ?? undefined, - ip: networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), - }, { - action: ApplicationEventName.USER_SIGNED_IN, - data: {}, - }) - rejectedPromiseHandler(telemetry(request.log).trackUser(response.id, { - name: TelemetryEventName.SIGNED_IN, - payload: { - userId: response.id, - platformId: response.platformId, - }, - }, { platform: response.platformId }), request.log) - } - - return response - }) - app.post('/complete-sign-up', CompleteSignUpRequestOptions, async (request) => { const { response, signedUp } = await passwordlessAuthService(request.log).completeSignUp({ identityId: request.principal.id, @@ -186,30 +144,10 @@ const CompleteSignUpRequestOptions = { }, } -const RequestEmailCodeRequestOptions = { - config: { - security: securityAccess.public(), - rateLimit: emailCodeRateLimit, - }, - schema: { - body: RequestEmailCodeRequest, - }, -} - function clientIp(request: FastifyRequest): string { return networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)) } -const VerifyEmailCodeRequestOptions = { - config: { - security: securityAccess.public(), - rateLimit: authnRateLimit, - }, - schema: { - body: VerifyEmailCodeRequest, - }, -} - const SignInRequestOptions = { config: { security: securityAccess.public(), diff --git a/packages/server/api/src/app/authentication/passwordless-auth.controller.ts b/packages/server/api/src/app/authentication/passwordless-auth.controller.ts new file mode 100644 index 000000000000..b3b34a095212 --- /dev/null +++ b/packages/server/api/src/app/authentication/passwordless-auth.controller.ts @@ -0,0 +1,77 @@ +import { isNil } from '@activepieces/core-utils' +import { ApplicationEventName, RequestEmailCodeRequest, TelemetryEventName, VerifyEmailCodeRequest } from '@activepieces/shared' +import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' +import { StatusCodes } from 'http-status-codes' +import { securityAccess } from '../core/security/authorization/fastify-security' +import { authnRateLimit, emailCodeRateLimit } from '../core/security/rate-limit' +import { applicationEvents } from '../helper/application-events' +import { networkUtils } from '../helper/network-utils' +import { rejectedPromiseHandler } from '../helper/promise-handler' +import { telemetry } from '../helper/telemetry.utils' +import { platformUtils } from '../platform/platform.utils' +import { passwordlessAuthService } from './passwordless-auth.service' + +export const passwordlessAuthController: FastifyPluginAsyncZod = async ( + app, +) => { + app.post('/otp/request', RequestEmailCodeRequestOptions, async (request, reply) => { + const platformId = await platformUtils.getPlatformIdForRequest(request) + await passwordlessAuthService(request.log).requestCode({ + email: request.body.email, + platformId: platformId ?? null, + captchaToken: request.body.captchaToken, + remoteIp: networkUtils.clientIp(request), + }) + return reply.code(StatusCodes.NO_CONTENT).send() + }) + + app.post('/otp/verify', VerifyEmailCodeRequestOptions, async (request) => { + const platformId = await platformUtils.getPlatformIdForRequest(request) + const response = await passwordlessAuthService(request.log).verifyCode({ + email: request.body.email, + code: request.body.code, + platformId: platformId ?? null, + }) + + if (!isNil(response.platformId)) { + applicationEvents(request.log).sendUserEvent({ + platformId: response.platformId, + userId: response.id, + projectId: response.projectId ?? undefined, + ip: networkUtils.clientIp(request), + }, { + action: ApplicationEventName.USER_SIGNED_IN, + data: {}, + }) + rejectedPromiseHandler(telemetry(request.log).trackUser(response.id, { + name: TelemetryEventName.SIGNED_IN, + payload: { + userId: response.id, + platformId: response.platformId, + }, + }, { platform: response.platformId }), request.log) + } + + return response + }) +} + +const RequestEmailCodeRequestOptions = { + config: { + security: securityAccess.public(), + rateLimit: emailCodeRateLimit, + }, + schema: { + body: RequestEmailCodeRequest, + }, +} + +const VerifyEmailCodeRequestOptions = { + config: { + security: securityAccess.public(), + rateLimit: authnRateLimit, + }, + schema: { + body: VerifyEmailCodeRequest, + }, +} diff --git a/packages/server/api/src/app/authentication/passwordless-auth.module.ts b/packages/server/api/src/app/authentication/passwordless-auth.module.ts new file mode 100644 index 000000000000..e79a9cdeeb36 --- /dev/null +++ b/packages/server/api/src/app/authentication/passwordless-auth.module.ts @@ -0,0 +1,13 @@ +import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' +import { turnstile } from './lib/turnstile' +import { passwordlessAuthController } from './passwordless-auth.controller' + +export const passwordlessAuthModule: FastifyPluginAsyncZod = async (app) => { + if (!turnstile.isConfigured()) { + app.log.error('[passwordlessAuthModule] signing in with an emailed code stays off: it needs a captcha, so set AP_TURNSTILE_SITE_KEY and AP_TURNSTILE_SECRET_KEY to switch it on') + return + } + await app.register(passwordlessAuthController, { + prefix: '/v1/authentication', + }) +} diff --git a/packages/server/api/src/app/ee/agent/agent-controller.ts b/packages/server/api/src/app/ee/agent/agent-controller.ts index a8209a99236a..9cdc4929d2e5 100644 --- a/packages/server/api/src/app/ee/agent/agent-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-controller.ts @@ -37,6 +37,8 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { platformId: request.principal.platform.id, userId: await resolveUserId(request), projectId: request.query.projectId, + search: request.query.search, + sort: request.query.sort, cursor: request.query.cursor, limit: request.query.limit, }) diff --git a/packages/server/api/src/app/ee/agent/agent-service.ts b/packages/server/api/src/app/ee/agent/agent-service.ts index 56bc8b3b754e..7e2ecf235259 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { AgentToolType, McpAuthType } from '@activepieces/core-piece-types' import { ActivepiecesError, ApId, apId, Cursor, ErrorCode, isNil, omit, Permission, PlatformId, ProjectId, sanitizeObjectForPostgresql, SeekPage, UserId } from '@activepieces/core-utils' -import { Agent, AgentConfig, AgentSummary, agentUtils, AgentVisibility, CreateAgentRequest, DEFAULT_CHAT_TIER_ID, DefaultProjectRole, Project, ProjectType, UpdateAgentRequest } from '@activepieces/shared' +import { Agent, AgentConfig, AgentListSort, AgentSummary, agentUtils, AgentVisibility, CreateAgentRequest, DEFAULT_CHAT_TIER_ID, DefaultProjectRole, Project, ProjectType, UpdateAgentRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { Brackets, In, SelectQueryBuilder } from 'typeorm' import { repoFactory } from '../../core/db/repo-factory' @@ -9,6 +9,7 @@ import { transaction } from '../../core/db/transaction' import { publishedFlowsUsingAgent, PublishedFlowsUsingAgent } from '../../flows/flow-version/flow-version.service' import { buildPaginator } from '../../helper/pagination/build-paginator' import { paginationHelper } from '../../helper/pagination/pagination-utils' +import { Order, OrderByConfig } from '../../helper/pagination/paginator' import { resolvePermissionChecker } from '../../mcp/mcp-permissions' import { projectService } from '../../project/project-service' import { userService } from '../../user/user-service' @@ -44,7 +45,7 @@ export const agentService = (log: FastifyBaseLogger) => ({ }) }, - async list({ platformId, userId, projectId, cursor, limit }: ListParams): Promise> { + async list({ platformId, userId, projectId, search, sort, cursor, limit }: ListParams): Promise> { const readableProjects = await resolveReadableProjects({ platformId, userId, projectId, log }) const readableProjectIds = readableProjects.map((project) => project.id) if (readableProjectIds.length === 0) { @@ -57,15 +58,22 @@ export const agentService = (log: FastifyBaseLogger) => ({ entity: AgentEntity, query: { limit: limit ?? DEFAULT_PAGE_SIZE, - order: 'DESC', + orderBy: orderByForSort(sort), afterCursor: nextCursor, beforeCursor: previousCursor, }, }) - const { data, cursor: newCursor } = await paginator.paginate( - visibleAgents({ userId, isProjectAdmin: false }).andWhere({ projectId: In(readableProjectIds) }), - ) + const query = visibleAgents({ userId, isProjectAdmin: false }).andWhere({ projectId: In(readableProjectIds) }) + const needle = search?.trim().toLowerCase() + if (!isNil(needle) && needle.length > 0) { + query.andWhere(new Brackets((qb) => { + qb.where('LOWER(agent."displayName") LIKE :needle', { needle: `%${needle}%` }) + .orWhere('LOWER(COALESCE(agent."description", \'\')) LIKE :needle', { needle: `%${needle}%` }) + })) + } + + const { data, cursor: newCursor } = await paginator.paginate(query) return paginationHelper.createPage(data.map((agent) => toSummary(agent, projectById.get(agent.projectId))), newCursor) }, @@ -200,6 +208,18 @@ function describeFlowsInUse({ total, names }: PublishedFlowsUsingAgent): string return `This agent is running in ${counted} (${listed}${tail}). Remove it from them first.` } +function orderByForSort(sort?: AgentListSort): OrderByConfig[] { + switch (sort) { + case AgentListSort.NAME: + return [{ field: 'displayName', order: Order.ASC }] + case AgentListSort.CREATED: + return [{ field: 'created', order: Order.DESC }] + case AgentListSort.UPDATED: + default: + return [{ field: 'updated', order: Order.DESC }] + } +} + function visibleAgents({ userId, isProjectAdmin }: { userId: UserId, isProjectAdmin: boolean }): SelectQueryBuilder { return agentRepo() .createQueryBuilder('agent') @@ -359,6 +379,8 @@ type ListParams = { platformId: PlatformId userId: UserId projectId?: ProjectId + search?: string + sort?: AgentListSort cursor?: Cursor limit?: number } diff --git a/packages/server/api/src/app/flags/flag.service.ts b/packages/server/api/src/app/flags/flag.service.ts index 8d5ff30b127c..b488a2742835 100644 --- a/packages/server/api/src/app/flags/flag.service.ts +++ b/packages/server/api/src/app/flags/flag.service.ts @@ -37,6 +37,7 @@ export const flagService = (log: FastifyBaseLogger) => ({ ApFlagId.CURRENT_VERSION, ApFlagId.EDITION, ApFlagId.EMAIL_AUTH_ENABLED, + ApFlagId.EMAIL_CODE_AUTH_ENABLED, ApFlagId.EXECUTION_DATA_RETENTION_DAYS, ApFlagId.ENVIRONMENT, ApFlagId.PUBLIC_URL, @@ -152,6 +153,12 @@ export const flagService = (log: FastifyBaseLogger) => ({ created, updated, }, + { + id: ApFlagId.EMAIL_CODE_AUTH_ENABLED, + value: system.getEdition() === ApEdition.CLOUD && turnstile.isConfigured(), + created, + updated, + }, { id: ApFlagId.THEME, value: defaultTheme, diff --git a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-grants.controller.ts b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-grants.controller.ts new file mode 100644 index 000000000000..47c29990e8ad --- /dev/null +++ b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-grants.controller.ts @@ -0,0 +1,69 @@ +import { SeekPage } from '@activepieces/core-utils' +import { + ListMcpOAuthGrantsRequestQuery, + McpOAuthGrant, + PrincipalType, + RevokeMcpOAuthGrantsRequestBody, +} from '@activepieces/shared' +import { FastifyRequest } from 'fastify' +import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' +import { StatusCodes } from 'http-status-codes' +import { z } from 'zod' +import { securityAccess } from '../../../core/security/authorization/fastify-security' +import { userService } from '../../../user/user-service' +import { mcpOAuthTokenService } from './mcp-oauth-token.service' + +export const mcpOAuthGrantsController: FastifyPluginAsyncZod = async (app) => { + + app.get('/v1/mcp-oauth/grants', ListGrantsRequest, async (req): Promise> => { + return mcpOAuthTokenService.listGrants({ + platformId: req.principal.platform.id, + userId: await resolveUserIdFilter(req), + projectIds: req.query.projectIds, + memberIds: req.query.memberIds, + clientKeys: req.query.clientKeys, + cursor: req.query.cursor, + limit: req.query.limit, + }) + }) + + app.post('/v1/mcp-oauth/grants/revoke', RevokeGrantsRequest, async (req, reply) => { + await mcpOAuthTokenService.revokeGrants({ + ids: req.body.ids, + platformId: req.principal.platform.id, + userId: await resolveUserIdFilter(req), + }) + return reply.status(StatusCodes.NO_CONTENT).send() + }) +} + +async function resolveUserIdFilter(req: FastifyRequest): Promise { + const user = await userService(req.log).getOneOrFail({ id: req.principal.id }) + return userService(req.log).isUserPrivileged(user) ? null : req.principal.id +} + +const ListGrantsRequest = { + config: { + security: securityAccess.publicPlatform([PrincipalType.USER]), + }, + schema: { + tags: ['mcp-oauth'], + querystring: ListMcpOAuthGrantsRequestQuery, + response: { + [StatusCodes.OK]: SeekPage(McpOAuthGrant), + }, + }, +} + +const RevokeGrantsRequest = { + config: { + security: securityAccess.publicPlatform([PrincipalType.USER]), + }, + schema: { + tags: ['mcp-oauth'], + body: RevokeMcpOAuthGrantsRequestBody, + response: { + [StatusCodes.NO_CONTENT]: z.never(), + }, + }, +} diff --git a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.service.ts b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.service.ts index 25d2ee0af0c8..8862b50ade2f 100644 --- a/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.service.ts +++ b/packages/server/api/src/app/mcp/oauth/token/mcp-oauth-token.service.ts @@ -1,18 +1,28 @@ import { randomBytes } from 'crypto' -import { apId, isNil, sanitizeObjectForPostgresql, spreadIfDefined } from '@activepieces/core-utils' +import { ActivepiecesError, apId, ErrorCode, isNil, sanitizeObjectForPostgresql, SeekPage, spreadIfDefined, unique } from '@activepieces/core-utils' import { cryptoUtils } from '@activepieces/server-utils' -import { McpOAuthToken } from '@activepieces/shared' +import { McpOAuthClientKey, McpOAuthGrant, McpOAuthToken, PLATFORM_WIDE_PROJECT_FILTER_VALUE, UserWithMetaInformation } from '@activepieces/shared' +import { Brackets, In, ObjectLiteral, SelectQueryBuilder } from 'typeorm' import { repoFactory } from '../../../core/db/repo-factory' import { JwtAudience, jwtUtils } from '../../../helper/jwt-utils' +import { buildPaginator } from '../../../helper/pagination/build-paginator' +import { paginationHelper } from '../../../helper/pagination/pagination-utils' +import { projectRepo } from '../../../project/project-repo' +import { mapToUserWithMetaInformation, userRepo } from '../../../user/user-service' import { mcpOAuthClientIdentity } from '../client/mcp-oauth-client-identity' +import { McpOAuthClientEntity } from '../client/mcp-oauth-client.entity' import { mcpOAuthPkce } from '../mcp-oauth.pkce' import { McpOAuthTokenEntity } from './mcp-oauth-token.entity' const repo = repoFactory(McpOAuthTokenEntity) +const clientRepo = repoFactory(McpOAuthClientEntity) const ACCESS_TOKEN_TTL_15_MINUTES_SECONDS = 15 * 60 const REFRESH_TOKEN_TTL_30_DAYS_MS = 30 * 24 * 60 * 60 * 1000 const INTERNAL_CHAT_CLIENT_ID = 'internal-chat' +const DEFAULT_GRANT_PAGE_SIZE = 20 +const TOKEN_ALIAS = 'mcp_oauth_token' +const UNKNOWN_CLIENT_KEY: McpOAuthClientKey = 'unknown' function generateRefreshToken(): string { return randomBytes(48).toString('base64url') @@ -130,11 +140,121 @@ export const mcpOAuthTokenService = { await repo().update({ refreshToken: hashRefreshToken(refreshToken), clientId }, { revoked: true }) }, + async listGrants({ platformId, userId, projectIds, memberIds, clientKeys, cursor, limit }: ListGrantsParams): Promise> { + const decodedCursor = paginationHelper.decodeCursor(cursor ?? null) + const paginator = buildPaginator({ + entity: McpOAuthTokenEntity, + query: { + limit: limit ?? DEFAULT_GRANT_PAGE_SIZE, + order: 'DESC', + afterCursor: decodedCursor.nextCursor, + beforeCursor: decodedCursor.previousCursor, + }, + }) + const queryBuilder = repo().createQueryBuilder(TOKEN_ALIAS) + applyGrantScope(queryBuilder, { platformId, userId }) + if (!isNil(clientKeys)) { + queryBuilder.andWhere(`COALESCE(${TOKEN_ALIAS}."clientKey", :unknownClientKey) IN (:...clientKeys)`, { clientKeys, unknownClientKey: UNKNOWN_CLIENT_KEY }) + } + if (!isNil(memberIds)) { + queryBuilder.andWhere(`${TOKEN_ALIAS}."userId" IN (:...memberIds)`, { memberIds }) + } + applyProjectFilter(queryBuilder, projectIds) + + const { data, cursor: nextCursor } = await paginator.paginate(queryBuilder) + + const [clientNames, members, projectNames] = await Promise.all([ + findClientNames({ clientIds: data.filter((token) => isNil(token.clientKey) || token.clientKey === UNKNOWN_CLIENT_KEY).map((token) => token.clientId) }), + findMembers({ userIds: data.map((token) => token.userId), platformId }), + findProjectNames({ projectIds: data.map((token) => token.projectId), platformId }), + ]) + + const rows = data.map((token) => ({ + id: token.id, + clientKey: token.clientKey ?? UNKNOWN_CLIENT_KEY, + clientName: clientNames.get(token.clientId) ?? null, + projectId: token.projectId, + projectName: isNil(token.projectId) ? null : projectNames.get(token.projectId) ?? null, + member: members.get(token.userId) ?? null, + created: token.created, + lastUsedAt: token.lastUsedAt, + })) + + return paginationHelper.createPage(rows, nextCursor) + }, + + async revokeGrants({ ids, userId, platformId }: RevokeGrantsParams): Promise { + const matched = await repo().findBy({ platformId, id: In(ids), ...spreadIfDefined('userId', userId ?? undefined) }) + if (matched.length !== unique(ids).length) { + throw new ActivepiecesError({ + code: ErrorCode.AUTHORIZATION, + params: { message: 'One or more grants do not exist or are not yours to revoke' }, + }) + } + await repo().update({ id: In(matched.map((token) => token.id)) }, { revoked: true }) + }, + async issueInternalAccessToken({ userId, platformId, projectId }: { userId: string, platformId: string, projectId: string | null }): Promise { return issueAccessToken({ userId, platformId, projectId, clientId: INTERNAL_CHAT_CLIENT_ID, scopes: ['mcp'] }) }, } +function applyGrantScope(queryBuilder: SelectQueryBuilder, { platformId, userId }: GrantScope): void { + queryBuilder + .where(`${TOKEN_ALIAS}."platformId" = :platformId`, { platformId }) + .andWhere(`${TOKEN_ALIAS}.revoked = false`) + .andWhere(`${TOKEN_ALIAS}."expiresAt" > :now`, { now: new Date().toISOString() }) + if (!isNil(userId)) { + queryBuilder.andWhere(`${TOKEN_ALIAS}."userId" = :userId`, { userId }) + } +} + +function applyProjectFilter(queryBuilder: SelectQueryBuilder, projectIds: string[] | undefined): void { + if (isNil(projectIds)) { + return + } + const scopedProjectIds = projectIds.filter((projectId) => projectId !== PLATFORM_WIDE_PROJECT_FILTER_VALUE) + const includesPlatformWide = projectIds.length !== scopedProjectIds.length + queryBuilder.andWhere(new Brackets((qb) => { + if (scopedProjectIds.length > 0) { + qb.orWhere(`${TOKEN_ALIAS}."projectId" IN (:...scopedProjectIds)`, { scopedProjectIds }) + } + if (includesPlatformWide) { + qb.orWhere(`${TOKEN_ALIAS}."projectId" IS NULL`) + } + })) +} + +async function findClientNames({ clientIds }: FindClientNamesParams): Promise> { + const distinct = unique(clientIds) + if (distinct.length === 0) { + return new Map() + } + const clients = await clientRepo().findBy({ clientId: In(distinct) }) + return new Map(clients.map((client) => [client.clientId, client.clientName])) +} + +async function findProjectNames({ projectIds, platformId }: FindProjectNamesParams): Promise> { + const distinct = unique(projectIds.filter((projectId): projectId is string => !isNil(projectId))) + if (distinct.length === 0) { + return new Map() + } + const projects = await projectRepo().findBy({ id: In(distinct), platformId }) + return new Map(projects.map((project) => [project.id, project.displayName])) +} + +async function findMembers({ userIds, platformId }: FindMembersParams): Promise> { + const distinct = unique(userIds) + if (distinct.length === 0) { + return new Map() + } + const users = await userRepo().find({ where: { id: In(distinct), platformId }, relations: { identity: true } }) + return new Map(users.flatMap((user) => { + const member = mapToUserWithMetaInformation(user) + return isNil(member) ? [] : [[user.id, member] as const] + })) +} + export class OAuthTokenError extends Error { constructor( public readonly errorCode: string, @@ -169,6 +289,37 @@ type RevokeRefreshTokenParams = { clientId: string } +type FindClientNamesParams = { + clientIds: string[] +} + +type FindProjectNamesParams = { + projectIds: (string | null)[] + platformId: string +} + +type FindMembersParams = { + userIds: string[] + platformId: string +} + +type GrantScope = { + platformId: string + userId: string | null +} + +type ListGrantsParams = GrantScope & { + projectIds?: string[] + memberIds?: string[] + clientKeys?: McpOAuthClientKey[] + cursor?: string + limit?: number +} + +type RevokeGrantsParams = GrantScope & { + ids: string[] +} + type RefreshParams = { redirectUris: string[] refreshToken: string diff --git a/packages/server/api/test/helpers/db.ts b/packages/server/api/test/helpers/db.ts index 0b31d8256a58..9bed94d17c8d 100644 --- a/packages/server/api/test/helpers/db.ts +++ b/packages/server/api/test/helpers/db.ts @@ -10,6 +10,10 @@ export const db = { return databaseConnection().getRepository(entity).update(id, data) }, + delete(entity: string, id: string): Promise { + return databaseConnection().getRepository(entity).delete(id) + }, + findOneByOrFail(entity: string, where: Record): Promise { return databaseConnection().getRepository(entity).findOneByOrFail(where) as Promise }, diff --git a/packages/server/api/test/integration/ce/authentication/passwordless-not-served.test.ts b/packages/server/api/test/integration/ce/authentication/passwordless-not-served.test.ts new file mode 100644 index 000000000000..c4c8b35fcf05 --- /dev/null +++ b/packages/server/api/test/integration/ce/authentication/passwordless-not-served.test.ts @@ -0,0 +1,52 @@ +import { ApFlagId } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance | null = null + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +describe('signing in with an emailed code on a self-hosted instance', () => { + it.each([ + ['/api/v1/authentication/otp/request', { email: 'someone@example.com' }], + ['/api/v1/authentication/otp/verify', { email: 'someone@example.com', code: '424242' }], + ])('does not serve %s', async (url, body) => { + const response = await app?.inject({ method: 'POST', url, body }) + + expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('still serves the password routes it replaced', async () => { + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-in', + body: { email: 'nobody@example.com', password: 'whatever-123' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.UNAUTHORIZED) + }) + + it('still serves /complete-sign-up, which finishes any onboarding session', async () => { + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + body: { fullName: 'Someone Else' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + }) + + it('tells the frontend the code flow is off', async () => { + const response = await app?.inject({ method: 'GET', url: '/api/v1/flags' }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json()[ApFlagId.EMAIL_CODE_AUTH_ENABLED]).toBe(false) + }) +}) diff --git a/packages/server/api/test/integration/ce/mcp/mcp-oauth-grants.test.ts b/packages/server/api/test/integration/ce/mcp/mcp-oauth-grants.test.ts new file mode 100644 index 000000000000..01c4f1f396f7 --- /dev/null +++ b/packages/server/api/test/integration/ce/mcp/mcp-oauth-grants.test.ts @@ -0,0 +1,289 @@ +import { apId } from '@activepieces/core-utils' +import { DefaultProjectRole, McpOAuthGrant, PlatformRole } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mcpOAuthClientIdentity } from '../../../../src/app/mcp/oauth/client/mcp-oauth-client-identity' +import { db } from '../../../helpers/db' +import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance | null = null +let ctx: TestContext + +const IN_30_DAYS = () => new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() +const YESTERDAY = () => new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() + +const CLAUDE_REDIRECT = 'https://claude.ai/api/mcp/auth_callback' +const CURSOR_REDIRECT = 'cursor://anysphere.cursor-retrieval/oauth/callback' +const CODEX_REDIRECT = 'http://localhost:1455/callback/abc' + +async function grantAccess({ userId, projectId, platformId, redirectUris, expiresAt = IN_30_DAYS(), revoked = false, unidentified = false }: { + userId: string + projectId: string | null + platformId?: string + redirectUris: string[] + expiresAt?: string + revoked?: boolean + unidentified?: boolean +}): Promise { + const clientId = apId() + await db.save('mcp_oauth_client', { + id: apId(), + clientId, + clientSecret: null, + clientSecretExpiresAt: 0, + clientIdIssuedAt: Math.floor(Date.now() / 1000), + redirectUris, + clientName: 'A Registered Name', + grantTypes: ['authorization_code'], + tokenEndpointAuthMethod: 'none', + created: new Date().toISOString(), + updated: new Date().toISOString(), + }) + const id = apId() + await db.save('mcp_oauth_token', { + id, + refreshToken: apId() + apId(), + clientId, + clientKey: unidentified ? null : mcpOAuthClientIdentity.detectClientKey({ redirectUris }), + userId, + projectId, + platformId: platformId ?? ctx.platform.id, + scopes: ['mcp'], + expiresAt, + revoked, + lastUsedAt: null, + created: new Date().toISOString(), + updated: new Date().toISOString(), + }) + return id +} + +async function promoteToOperator(memberCtx: TestContext): Promise { + await db.update('user', memberCtx.user.id, { platformRole: PlatformRole.OPERATOR }) +} + +describe('MCP OAuth connected clients', () => { + beforeAll(async () => { + app = await setupTestEnvironment() + }) + + beforeEach(async () => { + ctx = await createTestContext(app!) + }) + + describe('GET /v1/mcp-oauth/grants scope', () => { + it('lists every members grant on the platform for a platform admin', async () => { + const other = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.ADMIN }) + const mine = await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + const theirs = await grantAccess({ userId: other.user.id, projectId: ctx.project.id, redirectUris: [CURSOR_REDIRECT] }) + + const response = await ctx.get('/v1/mcp-oauth/grants') + + expect(response.statusCode).toBe(200) + const { data } = response.json() + expect(data.map((row: { id: string }) => row.id).sort()).toEqual([mine, theirs].sort()) + }) + + it('lists every members grant for a platform operator', async () => { + const operator = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.VIEWER }) + await promoteToOperator(operator) + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + await grantAccess({ userId: operator.user.id, projectId: ctx.project.id, redirectUris: [CURSOR_REDIRECT] }) + + const { data } = (await operator.get('/v1/mcp-oauth/grants')).json() + + expect(data).toHaveLength(2) + }) + + it('lists only their own grants for a non-privileged member', async () => { + const member = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.ADMIN }) + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + const theirs = await grantAccess({ userId: member.user.id, projectId: ctx.project.id, redirectUris: [CURSOR_REDIRECT] }) + + const { data } = (await member.get('/v1/mcp-oauth/grants')).json() + + expect(data).toHaveLength(1) + expect(data[0]).toMatchObject({ id: theirs, clientKey: 'cursor' }) + }) + + it('never crosses a platform boundary', async () => { + const elsewhere = await createTestContext(app!) + await grantAccess({ userId: elsewhere.user.id, projectId: elsewhere.project.id, platformId: elsewhere.platform.id, redirectUris: [CLAUDE_REDIRECT] }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants')).json() + + expect(data).toHaveLength(0) + }) + + it('hides expired and already-revoked grants', async () => { + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT], expiresAt: YESTERDAY() }) + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT], revoked: true }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants')).json() + + expect(data).toHaveLength(0) + }) + + it('renders a platform-wide grant with no project name, and the member who signed in', async () => { + await grantAccess({ userId: ctx.user.id, projectId: null, redirectUris: [CODEX_REDIRECT] }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants')).json() + + expect(data[0]).toMatchObject({ + clientKey: 'codex', + projectId: null, + projectName: null, + member: { id: ctx.user.id, email: ctx.userIdentity.email }, + }) + }) + + it('renders a null member when the user who signed in has been deleted', async () => { + const member = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.ADMIN }) + const orphaned = await grantAccess({ userId: member.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + await db.delete('user', member.user.id) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants')).json() + + expect(data.find((grant: { id: string }) => grant.id === orphaned)).toMatchObject({ + clientKey: 'claude', + member: null, + }) + }) + }) + + describe('GET /v1/mcp-oauth/grants filters', () => { + it('filters by client key', async () => { + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + const cursor = await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CURSOR_REDIRECT] }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants', { clientKeys: ['cursor'] })).json() + + expect(data).toHaveLength(1) + expect(data[0].id).toBe(cursor) + }) + + it('returns an empty page, not everything, when no client matches the key', async () => { + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants', { clientKeys: ['windsurf'] })).json() + + expect(data).toHaveLength(0) + }) + + it('reads a grant stored before the client key existed as unknown', async () => { + const legacy = await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT], unidentified: true }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants')).json() + + expect(data[0]).toMatchObject({ id: legacy, clientKey: 'unknown', clientName: 'A Registered Name' }) + }) + + it('matches a grant with no stored client key on the unknown key', async () => { + const legacy = await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT], unidentified: true }) + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CURSOR_REDIRECT] }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants', { clientKeys: ['unknown'] })).json() + + expect(data).toHaveLength(1) + expect(data[0].id).toBe(legacy) + }) + + it('filters by member', async () => { + const other = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.ADMIN }) + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + const theirs = await grantAccess({ userId: other.user.id, projectId: ctx.project.id, redirectUris: [CURSOR_REDIRECT] }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants', { memberIds: [other.user.id] })).json() + + expect(data).toHaveLength(1) + expect(data[0].id).toBe(theirs) + }) + + it('filters platform-wide grants by the platform-wide sentinel', async () => { + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + const platformWide = await grantAccess({ userId: ctx.user.id, projectId: null, redirectUris: [CODEX_REDIRECT] }) + + const { data } = (await ctx.get('/v1/mcp-oauth/grants', { projectIds: ['platform-wide'] })).json() + + expect(data).toHaveLength(1) + expect(data[0].id).toBe(platformWide) + }) + }) + + describe('GET /v1/mcp-oauth/grants pagination', () => { + it('walks every grant exactly once across pages', async () => { + const seeded = [ + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }), + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CURSOR_REDIRECT] }), + await grantAccess({ userId: ctx.user.id, projectId: null, redirectUris: [CODEX_REDIRECT] }), + ] + + const firstPage = (await ctx.get('/v1/mcp-oauth/grants', { limit: 2 })).json() + expect(firstPage.data).toHaveLength(2) + expect(firstPage.next).not.toBeNull() + + const secondPage = (await ctx.get('/v1/mcp-oauth/grants', { limit: 2, cursor: firstPage.next })).json() + expect(secondPage.data).toHaveLength(1) + expect(secondPage.next).toBeNull() + + const walked = [...firstPage.data, ...secondPage.data].map((grant: McpOAuthGrant) => grant.id) + expect(new Set(walked)).toEqual(new Set(seeded)) + }) + + it('carries the filters onto the next page', async () => { + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CURSOR_REDIRECT] }) + const claude = [ + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }), + await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }), + ] + + const firstPage = (await ctx.get('/v1/mcp-oauth/grants', { limit: 1, clientKeys: ['claude'] })).json() + const secondPage = (await ctx.get('/v1/mcp-oauth/grants', { limit: 1, clientKeys: ['claude'], cursor: firstPage.next })).json() + + const walked = [...firstPage.data, ...secondPage.data].map((grant: McpOAuthGrant) => grant.id) + expect(new Set(walked)).toEqual(new Set(claude)) + expect(secondPage.next).toBeNull() + }) + }) + + describe('POST /v1/mcp-oauth/grants/revoke', () => { + it('revokes a grant, which then leaves the list', async () => { + const id = await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + + const response = await ctx.post('/v1/mcp-oauth/grants/revoke', { ids: [id] }) + + expect(response.statusCode).toBe(204) + expect((await ctx.get('/v1/mcp-oauth/grants')).json().data).toHaveLength(0) + expect(await db.findOneBy('mcp_oauth_token', { id })).toMatchObject({ revoked: true }) + }) + + it('lets a platform admin revoke another members grant', async () => { + const other = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.ADMIN }) + const theirs = await grantAccess({ userId: other.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + + const response = await ctx.post('/v1/mcp-oauth/grants/revoke', { ids: [theirs] }) + + expect(response.statusCode).toBe(204) + expect(await db.findOneBy('mcp_oauth_token', { id: theirs })).toMatchObject({ revoked: true }) + }) + + it('writes nothing at all when one id in a members batch is not theirs', async () => { + const member = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.ADMIN }) + const theirs = await grantAccess({ userId: member.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + const mine = await grantAccess({ userId: ctx.user.id, projectId: ctx.project.id, redirectUris: [CLAUDE_REDIRECT] }) + + const response = await member.post('/v1/mcp-oauth/grants/revoke', { ids: [theirs, mine] }) + + expect(response.statusCode).toBe(403) + expect(await db.findOneBy('mcp_oauth_token', { id: theirs })).toMatchObject({ revoked: false }) + expect(await db.findOneBy('mcp_oauth_token', { id: mine })).toMatchObject({ revoked: false }) + }) + + it('rejects an empty batch', async () => { + const response = await ctx.post('/v1/mcp-oauth/grants/revoke', { ids: [] }) + + expect(response.statusCode).toBe(400) + }) + }) +}) diff --git a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts b/packages/server/api/test/integration/cloud/authn/passwordless-authn.test.ts similarity index 95% rename from packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts rename to packages/server/api/test/integration/cloud/authn/passwordless-authn.test.ts index 8cf5f27998f9..0e1104bfb1be 100644 --- a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts +++ b/packages/server/api/test/integration/cloud/authn/passwordless-authn.test.ts @@ -1,18 +1,19 @@ import { apId } from '@activepieces/core-utils' import { safeHttp } from '@activepieces/server-utils' -import { OtpState, OtpType, PlatformRole, UserIdentityProvider, UserStatus } from '@activepieces/shared' +import { ApFlagId, OtpState, OtpType, PlatformRole, UserIdentityProvider } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { passwordHasher } from '../../../../src/app/authentication/lib/password-hasher' +import { turnstile } from '../../../../src/app/authentication/lib/turnstile' import { otpService } from '../../../../src/app/authentication/otp/otp-service' import { userIdentityService } from '../../../../src/app/authentication/user-identity/user-identity-service' import { databaseConnection } from '../../../../src/app/database/database-connection' import { distributedStore } from '../../../../src/app/database/redis-connections' -import { passwordlessAuthService } from '../../../../src/app/authentication/passwordless-auth.service' -import { platformService } from '../../../../src/app/platform/platform.service' -import { createMockPlatform } from '../../../helpers/mocks' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' +process.env.AP_TURNSTILE_SITE_KEY = 'test-site-key' +process.env.AP_TURNSTILE_SECRET_KEY = 'test-secret-key' + let app: FastifyInstance | null = null const EMAIL = 'ahmad.tash@example.com' @@ -80,6 +81,7 @@ async function storedOtp(email: string) { } beforeAll(async () => { + vi.spyOn(turnstile, 'assertSolved').mockResolvedValue(undefined) app = await setupTestEnvironment() }) @@ -97,6 +99,13 @@ beforeEach(async () => { }) describe('Passwordless Authentication API', () => { + it('tells the frontend the code flow is on', async () => { + const response = await app?.inject({ method: 'GET', url: '/api/v1/flags' }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json()[ApFlagId.EMAIL_CODE_AUTH_ENABLED]).toBe(true) + }) + describe('Request code endpoint', () => { it('creates an unverified identity and issues a 6 digit code', async () => { const statusCode = await requestCode(EMAIL) @@ -174,7 +183,7 @@ describe('Passwordless Authentication API', () => { expect(await storedOtpRow(invited)).not.toBeNull() }) - it('issues a code with no captcha token when no challenge is configured', async () => { + it('stores the issued code as six digits', async () => { const statusCode = await requestCode(EMAIL) expect(statusCode).toBe(StatusCodes.NO_CONTENT) @@ -306,7 +315,7 @@ describe('Passwordless Authentication API', () => { const platform = await databaseConnection().getRepository('platform').findOneBy({ id: body?.platformId }) expect(platform?.name).toBe('Example') const project = await databaseConnection().getRepository('project').findOneBy({ platformId: body?.platformId }) - expect(project?.displayName).toBe("Example's Project") + expect(project?.displayName).toBe('Example\'s Project') }) it('falls back to the person when the address is a consumer provider', async () => { @@ -326,9 +335,9 @@ describe('Passwordless Authentication API', () => { expect(response?.statusCode).toBe(StatusCodes.OK) const body = response?.json() const platform = await databaseConnection().getRepository('platform').findOneBy({ id: body?.platformId }) - expect(platform?.name).toBe("Ahmad's Platform") + expect(platform?.name).toBe('Ahmad\'s Platform') const project = await databaseConnection().getRepository('project').findOneBy({ platformId: body?.platformId }) - expect(project?.displayName).toBe("Ahmad's Project") + expect(project?.displayName).toBe('Ahmad\'s Project') }) it('consumes one code exactly once, even when two confirmations race it', async () => { diff --git a/packages/server/api/test/integration/cloud/authn/passwordless-captcha-enforced.test.ts b/packages/server/api/test/integration/cloud/authn/passwordless-captcha-enforced.test.ts new file mode 100644 index 000000000000..9dd70e27afa7 --- /dev/null +++ b/packages/server/api/test/integration/cloud/authn/passwordless-captcha-enforced.test.ts @@ -0,0 +1,110 @@ +import { safeHttp } from '@activepieces/server-utils' +import { OtpType } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { databaseConnection } from '../../../../src/app/database/database-connection' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +process.env.AP_TURNSTILE_SITE_KEY = 'test-site-key' +process.env.AP_TURNSTILE_SECRET_KEY = 'test-secret-key' + +const SITE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + +let app: FastifyInstance | null = null +let callers = 0 + +function answerSiteVerify(success: boolean) { + return vi.spyOn(safeHttp.axios, 'post').mockResolvedValue({ data: { success } }) +} + +async function requestCode({ email, captchaToken }: RequestCodeParams) { + callers += 1 + return app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + headers: { 'x-real-ip': `10.9.${Math.floor(callers / 256)}.${callers % 256}` }, + body: { + email, + ...(captchaToken === undefined ? {} : { captchaToken }), + }, + }) +} + +async function storedIdentity(email: string) { + return databaseConnection().getRepository('user_identity').findOneBy({ email }) +} + +async function storedOtpRow(email: string) { + const identity = await storedIdentity(email) + if (identity === null) { + return null + } + return databaseConnection().getRepository('otp').findOneBy({ + identityId: identity.id, + type: OtpType.EMAIL_LOGIN, + }) +} + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +describe('the captcha in front of the emailed-code request endpoint', () => { + it('refuses a request carrying no captcha token, without asking cloudflare', async () => { + const email = 'no-token@example.com' + const siteVerify = answerSiteVerify(true) + + const response = await requestCode({ email }) + + expect(response?.statusCode).toBe(StatusCodes.CONFLICT) + expect(siteVerify).not.toHaveBeenCalled() + expect(await storedIdentity(email)).toBeNull() + siteVerify.mockRestore() + }) + + it('refuses a request whose token cloudflare rejects, and issues no code', async () => { + const email = 'rejected-token@example.com' + const siteVerify = answerSiteVerify(false) + + const response = await requestCode({ email, captchaToken: 'a-token-cloudflare-dislikes' }) + + expect(response?.statusCode).toBe(StatusCodes.CONFLICT) + expect(siteVerify).toHaveBeenCalledTimes(1) + expect(await storedIdentity(email)).toBeNull() + siteVerify.mockRestore() + }) + + it('sends the configured secret and the submitted token to cloudflare', async () => { + const email = 'forwards-token@example.com' + const siteVerify = answerSiteVerify(true) + + await requestCode({ email, captchaToken: 'a-token-worth-checking' }) + + const [url, body] = siteVerify.mock.calls[0] + expect(url).toBe(SITE_VERIFY_URL) + expect(body).toContain('secret=test-secret-key') + expect(body).toContain('response=a-token-worth-checking') + siteVerify.mockRestore() + }) + + it('issues a code once cloudflare accepts the token', async () => { + const email = 'accepted-token@example.com' + const siteVerify = answerSiteVerify(true) + + const response = await requestCode({ email, captchaToken: 'a-token-cloudflare-likes' }) + + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT) + expect(siteVerify).toHaveBeenCalledTimes(1) + expect(await storedOtpRow(email)).not.toBeNull() + siteVerify.mockRestore() + }) +}) + +type RequestCodeParams = { + email: string + captchaToken?: string +} diff --git a/packages/server/api/test/integration/cloud/authn/passwordless-requires-captcha.test.ts b/packages/server/api/test/integration/cloud/authn/passwordless-requires-captcha.test.ts new file mode 100644 index 000000000000..0dc0d1d4c65e --- /dev/null +++ b/packages/server/api/test/integration/cloud/authn/passwordless-requires-captcha.test.ts @@ -0,0 +1,50 @@ +import { ApFlagId } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance | null = null + +beforeAll(async () => { + delete process.env.AP_TURNSTILE_SITE_KEY + delete process.env.AP_TURNSTILE_SECRET_KEY + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +describe('signing in with an emailed code when no captcha is configured', () => { + it.each([ + ['/api/v1/authentication/otp/request', { email: 'someone@example.com' }], + ['/api/v1/authentication/otp/verify', { email: 'someone@example.com', code: '424242' }], + ])('does not serve %s', async (url, body) => { + const response = await app?.inject({ method: 'POST', url, body }) + + expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('still serves /complete-sign-up, which finishes any onboarding session', async () => { + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + body: { fullName: 'Someone Else' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + }) + + it('leaves the rest of the instance running', async () => { + const signIn = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-in', + body: { email: 'nobody@example.com', password: 'whatever-123' }, + }) + const flags = await app?.inject({ method: 'GET', url: '/api/v1/flags' }) + + expect(signIn?.statusCode).toBe(StatusCodes.UNAUTHORIZED) + expect(flags?.statusCode).toBe(StatusCodes.OK) + expect(flags?.json()[ApFlagId.EMAIL_CODE_AUTH_ENABLED]).toBe(false) + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts index ad06bdba9dfb..ab0f8023fc48 100644 --- a/packages/server/api/test/integration/ee/agent/agent-controller.test.ts +++ b/packages/server/api/test/integration/ee/agent/agent-controller.test.ts @@ -629,6 +629,54 @@ describe('agent list across projects', () => { expect(foreign).toStrictEqual([]) }) + it('searches by name and by description, and leaves the rest out', async () => { + const ctx = await context() + const inbox = await createAgent(ctx, { displayName: 'Inbox triage' }) + const pricing = await createAgent(ctx, { displayName: 'Rival watch', description: 'Reads competitor pricing pages.' }) + await createAgent(ctx, { displayName: 'Meeting notes', description: 'Turns notes into follow-ups.' }) + + const byName = (await ctx.get('/v1/agents', { search: 'inbox' })).json().data + expect(byName.map((row: { id: string }) => row.id)).toStrictEqual([inbox.id]) + + const byDescription = (await ctx.get('/v1/agents', { search: 'competitor' })).json().data + expect(byDescription.map((row: { id: string }) => row.id)).toStrictEqual([pricing.id]) + + const noMatch = (await ctx.get('/v1/agents', { search: 'nothing here' })).json().data + expect(noMatch).toStrictEqual([]) + }) + + it('sorts by name when asked, rather than by when it was touched', async () => { + const ctx = await context() + const apple = await createAgent(ctx, { displayName: 'Apple duty' }) + const zebra = await createAgent(ctx, { displayName: 'Zebra duty' }) + + const byName = (await ctx.get('/v1/agents', { sort: 'name' })).json().data + expect(byName.map((row: { id: string }) => row.id)).toStrictEqual([apple.id, zebra.id]) + + // The newest first, which is the opposite order, so the two cannot both pass by accident. + const byUpdated = (await ctx.get('/v1/agents', { sort: 'updated' })).json().data + expect(byUpdated.map((row: { id: string }) => row.id)).toStrictEqual([zebra.id, apple.id]) + }) + + it('walks every page with a cursor, so nothing is out of reach', async () => { + const ctx = await context() + const created = [] + for (const name of ['One', 'Two', 'Three']) { + created.push((await createAgent(ctx, { displayName: `Page ${name}` })).id) + } + + const seen: string[] = [] + let cursor: string | undefined = undefined + for (let page = 0; page < 5; page++) { + const body = (await ctx.get('/v1/agents', { limit: '1', ...(cursor === undefined ? {} : { cursor }) })).json() + seen.push(...body.data.map((row: { id: string }) => row.id)) + if (!body.next) break + cursor = body.next + } + + expect(seen.sort()).toStrictEqual([...created].sort()) + }) + it('refuses a page size that would disable pagination', async () => { const ctx = await context() diff --git a/packages/server/api/test/integration/ee/authn/passwordless-not-served.test.ts b/packages/server/api/test/integration/ee/authn/passwordless-not-served.test.ts new file mode 100644 index 000000000000..be77b638d8ee --- /dev/null +++ b/packages/server/api/test/integration/ee/authn/passwordless-not-served.test.ts @@ -0,0 +1,44 @@ +import { ApFlagId } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance | null = null + +beforeAll(async () => { + process.env.AP_TURNSTILE_SITE_KEY = 'test-site-key' + process.env.AP_TURNSTILE_SECRET_KEY = 'test-secret-key' + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +describe('signing in with an emailed code on an enterprise instance', () => { + it.each([ + ['/api/v1/authentication/otp/request', { email: 'someone@example.com' }], + ['/api/v1/authentication/otp/verify', { email: 'someone@example.com', code: '424242' }], + ])('does not serve %s, even with a captcha configured', async (url, body) => { + const response = await app?.inject({ method: 'POST', url, body }) + + expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('still serves /complete-sign-up, which finishes any onboarding session', async () => { + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + body: { fullName: 'Someone Else' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + }) + + it('tells the frontend the code flow is off', async () => { + const response = await app?.inject({ method: 'GET', url: '/api/v1/flags' }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json()[ApFlagId.EMAIL_CODE_AUTH_ENABLED]).toBe(false) + }) +}) diff --git a/packages/server/engine/src/lib/variables/processors/file.ts b/packages/server/engine/src/lib/variables/processors/file.ts index aa7244fcbbdd..d21f8085b405 100644 --- a/packages/server/engine/src/lib/variables/processors/file.ts +++ b/packages/server/engine/src/lib/variables/processors/file.ts @@ -52,6 +52,11 @@ function handleBase64File(propertyValue: string): ApFile | null { async function handleUrlFile(path: string): Promise { const fileResponse = await fetch(path) + // A 4xx/5xx body is the server's error page, not the file. handleStreamingFile + // already refuses one. + if (!fileResponse.ok) { + return null + } const filename = getFileName(path, fileResponse.headers.get('content-disposition'), fileResponse.headers.get('content-type') ?? undefined) ?? 'unknown' const extension = extensionFromFilename(filename) diff --git a/packages/server/engine/test/variables/file-processor.test.ts b/packages/server/engine/test/variables/file-processor.test.ts index d6bffe9ee504..26b835a9d6a1 100644 --- a/packages/server/engine/test/variables/file-processor.test.ts +++ b/packages/server/engine/test/variables/file-processor.test.ts @@ -103,6 +103,25 @@ describe('File Processor', () => { expect(file.body.destroyed).toBe(true) }) + it('resolves a buffered URL input to null when the URL responds with a non-ok status', async () => { + // An expired signed link is the common case: without this the caller + // received the storage provider's error page as the file. + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('AuthenticationFailed', { + status: 403, + headers: { 'content-type': 'application/xml' }, + }))) + + const { processedInput } = await propsProcessor.applyProcessorsAndValidators( + { file: FILE_URL }, + { file: Property.File({ displayName: 'File', required: false }) }, + PieceAuth.None(), + false, + {}, + ) + + expect(processedInput.file).toBeNull() + }) + it('resolves a streaming file property to a lazy body without buffering', async () => { const props = { file: Property.File({ displayName: 'File', required: true, streaming: true }), diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 420949c302bd..83613f7fde23 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -30,6 +30,9 @@ "Lead enrichment": "Lead enrichment", "Needs a model": "Needs a model", "New agent": "New agent", + "New agents go to": "New agents go to", + "Load more": "Load more", + "Showing {count} so far": "Showing {count} so far", "No provider is turned on for chat": "No provider is turned on for chat", "Popular starting points": "Popular starting points", "Read a support ticket, tag its severity, and route it to a team": "Read a support ticket, tag its severity, and route it to a team", @@ -2489,6 +2492,9 @@ "Private": "Private", "No description yet": "No description yet", "No agents match that search": "No agents match that search", + "No agents in this project yet": "No agents in this project yet", + "Describe one above, or pick another project.": "Describe one above, or pick another project.", + "Only you and the people you shared it with": "Only you and the people you shared it with", "Agent deleted": "Agent deleted", "Agents": "Agents", "Grid view": "Grid view", @@ -2547,6 +2553,9 @@ "Any MCP client": "Any MCP client", "All projects": "All projects", "MCP client": "MCP client", + "Revoke access": "Revoke access", + "Access ends within 15 minutes.": "Access ends within 15 minutes.", + "Could not revoke access. Try again.": "Could not revoke access. Try again.", "Add the connector": "Add the connector", "Add the server": "Add the server", "All clients": "All clients", @@ -2556,6 +2565,7 @@ "Chat apps": "Chat apps", "Check it works": "Check it works", "Check your clientโ€™s docs for where the server URL goes.": "Check your clientโ€™s docs for where the server URL goes.", + "Client": "Client", "Client not listed?": "Client not listed?", "Desktop and web ยท needs a public HTTPS address": "Desktop and web ยท needs a public HTTPS address", "Editor ยท runs locally": "Editor ยท runs locally", @@ -2578,12 +2588,16 @@ "Copy link": "Copy link", "From the folder you want the tools available in.": "From the folder you want the tools available in.", "If it answers with your tools, youโ€™re set.": "If it answers with your tools, youโ€™re set.", + "Manage connections": "Manage connections", "Need the exact steps?": "Need the exact steps?", "No API keys to manage": "No API keys to manage", + "No clients yet โ€” the first one to use the link shows up here.": "No clients yet โ€” the first one to use the link shows up here.", "One click install": "One click install", "One command": "One command", "One link for everywhere you use AI.": "One link for everywhere you use AI.", + "Pick a client": "Pick a client", "Pick a client for step-by-step setup, or copy the link and paste it wherever you like.": "Pick a client for step-by-step setup, or copy the link and paste it wherever you like.", + "Recently connected": "Recently connected", "Revoke any client in one click": "Revoke any client in one click", "Run this in your terminal": "Run this in your terminal", "Search {total} clients": "Search {total} clients", @@ -2592,6 +2606,7 @@ "Streamable HTTP or SSE. Point it at the link and it works.": "Streamable HTTP or SSE. Point it at the link and it works.", "Using something else?": "Using something else?", "Watch the full setup": "Watch the full setup", + "Waiting for first call": "Waiting for first call", "Where do you want to use it?": "Where do you want to use it?", "Your AI gets all of this": "Your AI gets all of this", "{count} pieces, plus every flow youโ€™ve built โ€” ready to run.": "{count} pieces, plus every flow youโ€™ve built โ€” ready to run.", @@ -2603,6 +2618,21 @@ "{client} docs": "{client} docs", "Or edit {path}": "Or edit {path}", "{client} opens your browser on the first tool call. Approve the project.": "{client} opens your browser on the first tool call. Approve the project.", + "Revoking {entityName}. Access ends within 15 minutes. The client will ask to sign in again.": "Revoking {entityName}. Access ends within 15 minutes. The client will ask to sign in again.", + "Active today": "Active today", + "Clear a filter to see more.": "Clear a filter to see more.", + "How connecting works": "How connecting works", + "Last used {date}": "Last used {date}", + "No connections match these filters": "No connections match these filters", + "Nothing has connected yet": "Nothing has connected yet", + "Same access as any other": "Same access as any other", + "Set it up in your client": "Set it up in your client", + "Two rows for one client is normal โ€” signing in again creates a second connection. Revoking one leaves the other alive.": "Two rows for one client is normal โ€” signing in again creates a second connection. Revoking one leaves the other alive.", + "When a client signs in with the link, it appears here with what it can reach.": "When a client signs in with the link, it appears here with what it can reach.", + "each expires 30 days after sign-in": "each expires 30 days after sign-in", + "revokeSelectedCount": "{count, plural, =1 {Revoke 1} other {Revoke #}}", + "revokedGrants": "{count, plural, =1 {1 connection} other {# connections}}", + "{name} ยท you": "{name} ยท you", "Opens Cursor and writes the server into ~/.cursor/mcp.json.": "Opens Cursor and writes the server into ~/.cursor/mcp.json.", "Paste the server URL as a custom connector. This client dials your server from the internet, so localhost will not reach it.": "Paste the server URL as a custom connector. This client dials your server from the internet, so localhost will not reach it.", "Paste the server URL as a connector. ChatGPT dials your server from the internet, so localhost will not reach it.": "Paste the server URL as a connector. ChatGPT dials your server from the internet, so localhost will not reach it.", diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx index c6d4872c9698..b65eded53191 100644 --- a/packages/web/src/app/routes/agents/id/index.tsx +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -484,15 +484,25 @@ const ConfigureFields = ({ defaultProvider={form.watch('draft.provider') ?? undefined} defaultModel={form.watch('draft.modelName') ?? undefined} defaultConfigId={form.watch('draft.providerConfigId') ?? undefined} - onChange={({ provider, model, configId }) => { - form.setValue('draft.provider', parseProvider(provider), { - shouldDirty: true, - }); - form.setValue('draft.modelName', model ?? null, { - shouldDirty: true, - }); - form.setValue('draft.providerConfigId', configId ?? null, { - shouldDirty: true, + onChange={({ provider, model, configId, picked: pickedBy }) => { + const picked = { + provider: parseProvider(provider) ?? null, + modelName: model ?? null, + providerConfigId: configId ?? null, + }; + if ( + !agentEditState.modelPickChanged({ + picked, + current: form.getValues('draft'), + }) + ) { + return; + } + const shouldDirty = pickedBy !== 'default'; + form.setValue('draft.provider', picked.provider, { shouldDirty }); + form.setValue('draft.modelName', picked.modelName, { shouldDirty }); + form.setValue('draft.providerConfigId', picked.providerConfigId, { + shouldDirty, }); }} /> @@ -675,12 +685,9 @@ const AgentEditScreen = ({ onEdited: () => void; }) => { const [mode, setMode] = useState('edit'); - const [syncedDraft, setSyncedDraft] = useState(() => - formValuesOf(agent), - ); const form = useForm({ resolver: zodResolver(ConfigureAgentSchema), - defaultValues: syncedDraft, + defaultValues: formValuesOf(agent), mode: 'onChange', }); const updateAgent = agentsMutations.useUpdateAgent({ id: agent.id }); @@ -702,10 +709,7 @@ const AgentEditScreen = ({ const live = liveValuesOf(agent); const hasChanges = isNil(live) || !agentEditState.sameConfig({ left: values, right: live }); - const unsavedTyping = !agentEditState.sameConfig({ - left: values, - right: syncedDraft, - }); + const unsavedTyping = form.formState.isDirty; const deletedRef = useRef(false); const leaveBlocker = useWarnBeforeLosingChanges({ hasChanges: unsavedTyping, @@ -720,22 +724,22 @@ const AgentEditScreen = ({ const writeSeq = useRef(0); const writeLock = useRef(agentEditState.createWriteLock()); + const lastFromServer = useRef(formValuesOf(agent)); + useEffect(() => { const fromServer = formValuesOf(agent); - if (agentEditState.sameConfig({ left: fromServer, right: syncedDraft })) + if ( + agentEditState.sameConfig({ + left: fromServer, + right: lastFromServer.current, + }) + ) { return; + } if (unsavedTyping) return; + lastFromServer.current = fromServer; form.reset(fromServer); - setSyncedDraft(fromServer); - }, [agent, syncedDraft, unsavedTyping, form]); - - // The model selector picks a default the moment it mounts, so a screen nobody has touched would - // otherwise arm the leave guard. Adopting that pick as the baseline keeps Save armed, since that - // compares against what is live, while the guard only speaks for changes a person made. - useEffect(() => { - if (!agentEditState.adoptsPickedModel({ values, syncedDraft })) return; - setSyncedDraft(values); - }, [values, syncedDraft]); + }, [agent, unsavedTyping, form]); const setServerError = (error: Error, fallback: string) => form.setError('root.serverError', { @@ -743,6 +747,15 @@ const AgentEditScreen = ({ message: api.extractServerErrorMessage(error, fallback), }); + const markSavedUnlessEditedSince = (written: ConfigureAgentInput) => { + if ( + !agentEditState.sameConfig({ left: form.getValues(), right: written }) + ) { + return; + } + form.reset(written); + }; + const releaseWrite = () => writeLock.current.release(); const claimWrite = () => writeLock.current.claim(); @@ -753,7 +766,7 @@ const AgentEditScreen = ({ { onSuccess: () => { if (seq !== writeSeq.current) return; - setSyncedDraft(values); + markSavedUnlessEditedSince(values); if (testRequested.current) setMode('test'); }, onError: (error) => @@ -787,7 +800,7 @@ const AgentEditScreen = ({ updateAgent.mutate(toUpdateRequest(values), { onSuccess: () => { if (seq !== writeSeq.current) return; - setSyncedDraft(values); + markSavedUnlessEditedSince(values); setJustLaunched(true); window.setTimeout(() => setJustLaunched(false), 1600); toast(t('Live โ€” every flow using this agent just got the update')); diff --git a/packages/web/src/app/routes/agents/index.tsx b/packages/web/src/app/routes/agents/index.tsx index 330a7b8a4255..53b12840b631 100644 --- a/packages/web/src/app/routes/agents/index.tsx +++ b/packages/web/src/app/routes/agents/index.tsx @@ -1,5 +1,6 @@ import { AgentIcon, + AgentListSort, AgentSummary, ColorName, MAX_DRAFT_PROMPT_LENGTH, @@ -18,6 +19,7 @@ import { } from 'lucide-react'; import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useDebounce } from 'use-debounce'; import { LockedFeatureGuard } from '@/app/components/locked-feature-guard'; import { @@ -27,6 +29,7 @@ import { EmptyMedia, EmptyTitle, } from '@/components/custom/empty'; +import { SearchableSelect } from '@/components/custom/searchable-select'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -46,13 +49,15 @@ import { } from '@/features/agents/hooks/agents-hooks'; import { createAgentUtils } from '@/features/agents/lib/create-agent-utils'; import { aiProviderQueries } from '@/features/platform-admin/hooks/ai-provider-hooks'; -import { projectCollectionUtils } from '@/features/projects'; +import { getProjectName, projectCollectionUtils } from '@/features/projects'; import { useIsPlatformAdmin } from '@/hooks/authorization-hooks'; import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; import { + acceptsDraftPrompt, showsAgentList, + shownDestination, showsFirstRun, showsNoMatchNotice, } from './lib/agents-list-state'; @@ -86,21 +91,14 @@ const TEMPLATE_STARTERS: TemplateStarter[] = [ }, ]; -const SORT_LABELS = { - updated: 'Recently updated', - created: 'Recently created', - name: 'Name', -} as const; - -const SORT_COMPARATORS: Record< - AgentSort, - (left: AgentSummary, right: AgentSummary) => number -> = { - updated: (left, right) => right.updated.localeCompare(left.updated), - created: (left, right) => right.created.localeCompare(left.created), - name: (left, right) => left.displayName.localeCompare(right.displayName), +const SORT_LABELS: Record = { + [AgentListSort.UPDATED]: 'Recently updated', + [AgentListSort.CREATED]: 'Recently created', + [AgentListSort.NAME]: 'Name', }; +const ALL_PROJECTS = 'all'; + const AgentsPage = () => { const agentsAvailable = useAgentsAvailable(); return ( @@ -118,27 +116,40 @@ const AgentsPage = () => { const AgentsPageContent = () => { const [search, setSearch] = useState(''); const [layout, setLayout] = useState<'grid' | 'list'>('grid'); - const [sort, setSort] = useState('updated'); + const [sort, setSort] = useState(AgentListSort.UPDATED); const [prompt, setPrompt] = useState(''); + const [viewProjectId, setViewProjectId] = useState(ALL_PROJECTS); + const [pickedProjectId, setPickedProjectId] = useState(null); + const [buildingInProjectId, setBuildingInProjectId] = useState( + null, + ); const navigate = useNavigate(); const { project } = projectCollectionUtils.useCurrentProject(); const { data: allProjects } = projectCollectionUtils.useAll(); const agentsAvailable = useAgentsAvailable(); const isPlatformAdmin = useIsPlatformAdmin(); - const { data, isLoading, isSuccess } = agentsQueries.useAgents({ + const projectFiltered = viewProjectId !== ALL_PROJECTS; + const createInProjectId = + pickedProjectId ?? (projectFiltered ? viewProjectId : project.id); + const [debouncedSearch] = useDebounce(search.trim(), 300); + const { + data, + isLoading, + isSuccess, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = agentsQueries.useAgents({ + ...(projectFiltered ? { projectId: viewProjectId } : {}), + ...(debouncedSearch.length > 0 ? { search: debouncedSearch } : {}), + sort, enabled: agentsAvailable, }); - const agents = useMemo(() => { - const needle = search.trim().toLowerCase(); - const matching = (data?.data ?? []).filter( - (agent) => - needle.length === 0 || - agent.displayName.toLowerCase().includes(needle) || - (agent.description ?? '').toLowerCase().includes(needle), - ); - return [...matching].sort(SORT_COMPARATORS[sort]); - }, [data, search, sort]); + const agents = useMemo( + () => (data?.pages ?? []).flatMap((page) => page.data), + [data], + ); const draftAgent = agentsMutations.useDraftAgent(); const createAgent = agentsMutations.useCreateAgent({ @@ -150,29 +161,54 @@ const AgentsPageContent = () => { data: chatProvider, isLoading: isLoadingProvider, isError: providerLookupFailed, - } = aiProviderQueries.useChatProvider(); - const { data: projectProviders } = aiProviderQueries.useProjectAiProviders(); + } = aiProviderQueries.useChatProvider(createInProjectId); + const { data: projectProviders } = + aiProviderQueries.useProjectAiProviders(createInProjectId); const needsProvider = !isLoadingProvider && !providerLookupFailed && chatProvider === undefined; const chatIsOffOnEveryProvider = needsProvider && (projectProviders?.length ?? 0) > 0; const isBuilding = draftAgent.isPending || createAgent.isPending; + const destinationReadinessUnknown = isLoadingProvider; const buildError = draftAgent.error ?? createAgent.error ?? null; + const shownDestinationId = shownDestination({ + isBuilding, + buildingIn: buildingInProjectId, + picked: createInProjectId, + }); + + const projectOptions = useMemo( + () => + (allProjects ?? []).map((entry) => ({ + value: entry.id, + label: getProjectName(entry), + })), + [allProjects], + ); + const buildAgent = (text?: string) => { const trimmed = (text ?? prompt).trim(); - if (trimmed.length === 0 || isBuilding) { + if ( + !acceptsDraftPrompt({ + prompt: trimmed, + isBuilding, + readinessUnknown: destinationReadinessUnknown, + }) + ) { return; } setPrompt(trimmed); + const destination = createInProjectId; + setBuildingInProjectId(destination); draftAgent.mutate( - { projectId: project.id, prompt: trimmed }, + { projectId: destination, prompt: trimmed }, { onSuccess: (draft) => createAgent.mutate( createAgentUtils.buildCreateRequest({ draft, - projectId: project.id, + projectId: destination, }), ), }, @@ -192,7 +228,7 @@ const AgentsPageContent = () => { color: ColorName.PURPLE, instructions: '', }, - projectId: project.id, + projectId: createInProjectId, }), ); }; @@ -204,8 +240,9 @@ const AgentsPageContent = () => { const firstRun = showsFirstRun({ listLoaded: isSuccess, - hasAnyAgents: (data?.data.length ?? 0) > 0, + hasAnyAgents: agents.length > 0, search, + projectFiltered, }); return ( @@ -335,7 +372,7 @@ const AgentsPageContent = () => {
+ {!needsProvider && (allProjects ?? []).length > 1 && ( +
+ {t('New agents go to')} + setPickedProjectId(value)} + options={projectOptions} + disabled={isBuilding} + placeholder={t('Search projects')} + contentWidth="260px" + triggerClassName="h-7 w-auto max-w-[220px] gap-1 border-0 bg-transparent px-1.5 text-[13px] font-medium shadow-none hover:bg-accent" + /> +
+ )} {buildError !== null && (

{api.extractServerErrorMessage( @@ -375,7 +426,7 @@ const AgentsPageContent = () => { + + )} )} ); }; -const AgentsEmptyState = () => ( +const AgentsEmptyState = ({ + narrowedByProject, +}: { + narrowedByProject: boolean; +}) => ( - {t('No agents match that search')} + + {narrowedByProject + ? t('No agents in this project yet') + : t('No agents match that search')} + - {t('Try another name, or clear the search.')} + {narrowedByProject + ? t('Describe one above, or pick another project.') + : t('Try another name, or clear the search.')} ); -type AgentSort = keyof typeof SORT_LABELS; - type TemplateStarter = { label: string; dot: string; diff --git a/packages/web/src/app/routes/agents/lib/agent-edit-state.ts b/packages/web/src/app/routes/agents/lib/agent-edit-state.ts index da1ce4c15cad..440c458e2b98 100644 --- a/packages/web/src/app/routes/agents/lib/agent-edit-state.ts +++ b/packages/web/src/app/routes/agents/lib/agent-edit-state.ts @@ -10,32 +10,19 @@ function sameConfig({ return JSON.stringify(left) === JSON.stringify(right); } -function adoptsPickedModel({ - values, - syncedDraft, +function modelPickChanged({ + picked, + current, }: { - values: AgentDraftShape; - syncedDraft: AgentDraftShape; + picked: ModelPick; + current: ModelPick; }): boolean { - const hadNoModel = - syncedDraft.draft.modelName === null || - syncedDraft.draft.modelName === undefined || - syncedDraft.draft.modelName === ''; - if (!hadNoModel) { - return false; - } - const withoutModel = (shape: AgentDraftShape) => ({ - ...shape, - draft: { - ...shape.draft, - provider: null, - modelName: null, - providerConfigId: null, - }, - }); - return ( - values.draft.modelName !== syncedDraft.draft.modelName && - sameConfig({ left: withoutModel(values), right: withoutModel(syncedDraft) }) + const same = (left?: string | null, right?: string | null) => + (left ?? null) === (right ?? null); + return !( + same(picked.provider, current.provider) && + same(picked.modelName, current.modelName) && + same(picked.providerConfigId, current.providerConfigId) ); } @@ -107,22 +94,18 @@ function createWriteLock(): WriteLock { export const agentEditState = { sameConfig, - adoptsPickedModel, + modelPickChanged, headerStatus, modeIntent, leaveGuard, createWriteLock, }; -export type AgentDraftShape = { - draft: { - provider?: unknown; - modelName?: string | null; - providerConfigId?: unknown; - [key: string]: unknown; - }; +export type ModelPick = { + provider?: string | null; + modelName?: string | null; + providerConfigId?: string | null; }; - export type HeaderStatus = 'needs-model' | 'live' | 'pending'; export type ModeIntent = 'switch' | 'stage'; export type LeaveGuard = { diff --git a/packages/web/src/app/routes/agents/lib/agents-list-state.ts b/packages/web/src/app/routes/agents/lib/agents-list-state.ts index 0654cb31c04d..d52324a4ca39 100644 --- a/packages/web/src/app/routes/agents/lib/agents-list-state.ts +++ b/packages/web/src/app/routes/agents/lib/agents-list-state.ts @@ -2,12 +2,19 @@ export function showsFirstRun({ listLoaded, hasAnyAgents, search, + projectFiltered = false, }: { listLoaded: boolean; hasAnyAgents: boolean; search: string; + projectFiltered?: boolean; }): boolean { - return listLoaded && !hasAnyAgents && search.trim().length === 0; + return ( + listLoaded && + !hasAnyAgents && + search.trim().length === 0 && + !projectFiltered + ); } export function showsAgentList({ @@ -22,12 +29,38 @@ export function showsAgentList({ return listLoading || (hasList && !firstRun); } +export function shownDestination({ + isBuilding, + buildingIn, + picked, +}: { + isBuilding: boolean; + buildingIn: string | null; + picked: string; +}): string { + return isBuilding && buildingIn !== null ? buildingIn : picked; +} + +export function acceptsDraftPrompt({ + prompt, + isBuilding, + readinessUnknown, +}: { + prompt: string; + isBuilding: boolean; + readinessUnknown: boolean; +}): boolean { + return prompt.trim().length > 0 && !isBuilding && !readinessUnknown; +} + export function showsNoMatchNotice({ matchCount, search, + projectFiltered = false, }: { matchCount: number; search: string; + projectFiltered?: boolean; }): boolean { - return matchCount === 0 && search.trim().length > 0; + return matchCount === 0 && (search.trim().length > 0 || projectFiltered); } diff --git a/packages/web/src/app/routes/mcp-server/connect/connect-landing.tsx b/packages/web/src/app/routes/mcp-server/connect/connect-landing.tsx index 076977c831dd..88d340590d39 100644 --- a/packages/web/src/app/routes/mcp-server/connect/connect-landing.tsx +++ b/packages/web/src/app/routes/mcp-server/connect/connect-landing.tsx @@ -7,6 +7,7 @@ import { CatalogClient, POPULAR_CLIENT_KEYS } from '../mcp-client-catalog'; import { useMcpNav } from '../mcp-nav'; import { PageBand } from '../page-band'; import { PiecesShowcase } from '../pieces-showcase'; +import { RecentlyConnected } from '../recently-connected'; import { ClientCard } from './client-card'; @@ -76,6 +77,7 @@ export function ConnectLanding({ + ); } diff --git a/packages/web/src/app/routes/mcp-server/grants/grant-utils.ts b/packages/web/src/app/routes/mcp-server/grants/grant-utils.ts new file mode 100644 index 000000000000..e14339368464 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/grants/grant-utils.ts @@ -0,0 +1,28 @@ +import { McpOAuthGrant } from '@activepieces/shared'; +import dayjs from 'dayjs'; +import { t } from 'i18next'; + +import { formatUtils } from '@/lib/format-utils'; + +function formatLastUsed(row: McpOAuthGrant): LastUsed { + if (row.lastUsedAt === null) { + return { label: t('Never used'), isActiveToday: false }; + } + const lastUsedAt = dayjs(row.lastUsedAt); + const isActiveToday = lastUsedAt.isSame(dayjs(), 'day'); + return { + label: isActiveToday + ? t('Active today') + : t('Last used {date}', { + date: formatUtils.formatDate(lastUsedAt.toDate()), + }), + isActiveToday, + }; +} + +export const grantUtils = { formatLastUsed }; + +export type LastUsed = { + label: string; + isActiveToday: boolean; +}; diff --git a/packages/web/src/app/routes/mcp-server/grants/grants-columns.tsx b/packages/web/src/app/routes/mcp-server/grants/grants-columns.tsx new file mode 100644 index 000000000000..de7c547bb6b5 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/grants/grants-columns.tsx @@ -0,0 +1,167 @@ +import { McpOAuthGrant } from '@activepieces/shared'; +import { ColumnDef } from '@tanstack/react-table'; +import { t } from 'i18next'; +import { Clock, FolderOpen, Plug, User } from 'lucide-react'; + +import { RowDataWithActions } from '@/components/custom/data-table'; +import { DataTableColumnHeader } from '@/components/custom/data-table/data-table-column-header'; +import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; +import { TextWithTooltip } from '@/components/custom/text-with-tooltip'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +import { ClientIcon } from '../client-icon'; +import { mcpClientDisplay } from '../mcp-client-display'; + +import { grantUtils } from './grant-utils'; + +export function buildGrantsColumns({ + currentUserId, + onRevoke, +}: { + currentUserId: string | undefined; + onRevoke: (ids: string[]) => Promise; +}): ColumnDef, unknown>[] { + return [ + { + accessorKey: 'client', + size: 260, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const label = mcpClientDisplay.label({ + key: row.original.clientKey, + clientName: row.original.clientName, + }); + return ( +

+ +
+ +
{label}
+
+ {row.original.clientKey === 'unknown' && ( +
+ {t('Same access as any other')} +
+ )} +
+
+ ); + }, + }, + { + accessorKey: 'project', + size: 180, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.projectName ?? t('All projects')} + + ), + }, + { + accessorKey: 'member', + size: 200, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { member } = row.original; + if (!member) { + return
โ€”
; + } + const name = `${member.firstName} ${member.lastName}`.trim(); + return ( + +
+ {member.id === currentUserId ? t('{name} ยท you', { name }) : name} +
+
+ ); + }, + }, + { + accessorKey: 'lastUsedAt', + size: 160, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const lastUsed = grantUtils.formatLastUsed(row.original); + return ( +
+ + + {lastUsed.label} + +
+ ); + }, + }, + { + accessorKey: 'actions', + size: 100, + header: () => {t('Revoke')}, + cell: ({ row }) => { + const clientLabel = mcpClientDisplay.label({ + key: row.original.clientKey, + clientName: row.original.clientName, + }); + return ( +
+ onRevoke([row.original.id])} + > + + +
+ ); + }, + }, + ]; +} diff --git a/packages/web/src/app/routes/mcp-server/grants/grants-tab.tsx b/packages/web/src/app/routes/mcp-server/grants/grants-tab.tsx new file mode 100644 index 000000000000..58943b93da98 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/grants/grants-tab.tsx @@ -0,0 +1,228 @@ +import { + McpOAuthClientKey, + PLATFORM_WIDE_PROJECT_FILTER_VALUE, +} from '@activepieces/shared'; +import { t } from 'i18next'; +import { CheckIcon, FolderOpen, Plug, User } from 'lucide-react'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { + CURSOR_QUERY_PARAM, + DataTable, + DataTableFilters, + LIMIT_QUERY_PARAM, +} from '@/components/custom/data-table'; +import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/custom/empty'; +import { Button } from '@/components/ui/button'; +import { platformUserHooks } from '@/features/platform-admin/hooks/platform-user-hooks'; +import { projectCollectionUtils } from '@/features/projects'; +import { userHooks } from '@/hooks/user-hooks'; + +import { mcpClientDisplay } from '../mcp-client-display'; +import { mcpGrantsMutations, mcpGrantsQueries } from '../mcp-grants-hooks'; +import { useMcpNav } from '../mcp-nav'; +import { PageBand } from '../page-band'; + +import { buildGrantsColumns } from './grants-columns'; + +const DOCS_URL = 'https://www.activepieces.com/docs/mcp/overview'; +const DEFAULT_PAGE_SIZE = 10; + +export function GrantsTab() { + const nav = useMcpNav(); + const [searchParams] = useSearchParams(); + const { data: currentUser } = userHooks.useCurrentUser(); + const { data: projects = [] } = projectCollectionUtils.useAll(); + const { data: users } = platformUserHooks.useUsers(); + const request = useMemo( + () => ({ + cursor: searchParams.get(CURSOR_QUERY_PARAM) ?? undefined, + limit: Number(searchParams.get(LIMIT_QUERY_PARAM)) || DEFAULT_PAGE_SIZE, + projectIds: undefinedIfEmpty(searchParams.getAll('project')), + memberIds: undefinedIfEmpty(searchParams.getAll('member')), + clientKeys: undefinedIfEmpty( + searchParams.getAll('client').filter(isClientKey), + ), + }), + [searchParams], + ); + const hasActiveFilters = + request.projectIds !== undefined || + request.memberIds !== undefined || + request.clientKeys !== undefined; + + const { data, isLoading, isError } = mcpGrantsQueries.useGrants({ + request, + showErrorDialog: true, + }); + const revoke = mcpGrantsMutations.useRevoke(); + + const columns = buildGrantsColumns({ + currentUserId: currentUser?.id, + onRevoke: async (ids) => { + await revoke.mutateAsync(ids); + }, + }); + + if ( + !isLoading && + !isError && + !hasActiveFilters && + (data?.data.length ?? 0) === 0 + ) { + return ( + + + + + + + {t('Nothing has connected yet')} + + {t( + 'When a client signs in with the link, it appears here with what it can reach.', + )} + + + + + + ); + } + + return ( + + + {t('each expires 30 days after sign-in')} + , + ]} + bulkActions={[ + { + render: (rows, resetSelection) => ( + { + await revoke.mutateAsync(rows.map((row) => row.id)); + resetSelection(); + }} + > + + + ), + }, + ]} + emptyStateTextTitle={t('No connections match these filters')} + emptyStateTextDescription={t('Clear a filter to see more.')} + emptyStateIcon={} + /> + +
+ + {t( + 'Two rows for one client is normal โ€” signing in again creates a second connection. Revoking one leaves the other alive.', + )} + + + {t('How connecting works')} โ†— + +
+
+ ); +} + +function undefinedIfEmpty(values: T[]): T[] | undefined { + return values.length === 0 ? undefined : values; +} + +function isClientKey(value: string): value is McpOAuthClientKey { + return McpOAuthClientKey.safeParse(value).success; +} + +function buildFilters({ + projects, + members, +}: { + projects: { id: string; displayName: string }[]; + members: { id: string; email: string; firstName: string; lastName: string }[]; +}): DataTableFilters[] { + const filters: DataTableFilters[] = []; + + if (projects.length > 1) { + filters.push({ + type: 'select', + title: t('Project'), + accessorKey: 'project', + icon: FolderOpen, + options: [ + ...projects.map((project) => ({ + label: project.displayName, + value: project.id, + })), + { + label: t('All projects'), + value: PLATFORM_WIDE_PROJECT_FILTER_VALUE, + }, + ], + }); + } + + if (members.length > 1) { + filters.push({ + type: 'select', + title: t('Member'), + accessorKey: 'member', + icon: User, + options: members.map((member) => ({ + label: `${member.firstName} ${member.lastName}`.trim() || member.email, + value: member.id, + })), + }); + } + + filters.push({ + type: 'select', + title: t('Client'), + accessorKey: 'client', + icon: CheckIcon, + options: McpOAuthClientKey.options.map((clientKey) => ({ + label: mcpClientDisplay.label({ key: clientKey, clientName: null }), + value: clientKey, + icon: mcpClientDisplay.icon(clientKey), + })), + }); + + return filters; +} diff --git a/packages/web/src/app/routes/mcp-server/index.tsx b/packages/web/src/app/routes/mcp-server/index.tsx index 5f64caa1bed1..95617dd5c828 100644 --- a/packages/web/src/app/routes/mcp-server/index.tsx +++ b/packages/web/src/app/routes/mcp-server/index.tsx @@ -1,22 +1,47 @@ import { t } from 'i18next'; import { PageHeader } from '@/components/custom/page-header'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { piecesHooks } from '@/features/pieces/hooks/pieces-hooks'; import { ConnectTab } from './connect/connect-tab'; +import { GrantsTab } from './grants/grants-tab'; +import { useMcpNav } from './mcp-nav'; import { useMcpServerUrl } from './mcp-server-url'; +import { PageBand } from './page-band'; export default function McpServerPage() { const { serverUrl, isReachableFromInternet } = useMcpServerUrl(); + const nav = useMcpNav(); piecesHooks.usePrefetchPieces({ skipProjectFilter: true }); return (
- +
+ + + + + {t('Connect')} + + + {t('Connections')} + + + + +
+
+ {nav.tab === 'connections' ? ( + + ) : ( + + )} +
); } diff --git a/packages/web/src/app/routes/mcp-server/mcp-grants-api.ts b/packages/web/src/app/routes/mcp-server/mcp-grants-api.ts new file mode 100644 index 000000000000..846b098d6e19 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/mcp-grants-api.ts @@ -0,0 +1,20 @@ +import { SeekPage } from '@activepieces/core-utils'; +import { + ListMcpOAuthGrantsRequestQuery, + McpOAuthGrant, + RevokeMcpOAuthGrantsRequestBody, +} from '@activepieces/shared'; + +import { api } from '@/lib/api'; + +export const mcpGrantsApi = { + list( + request: ListMcpOAuthGrantsRequestQuery, + ): Promise> { + return api.get>('/v1/mcp-oauth/grants', request); + }, + + revoke(request: RevokeMcpOAuthGrantsRequestBody): Promise { + return api.post('/v1/mcp-oauth/grants/revoke', request); + }, +}; diff --git a/packages/web/src/app/routes/mcp-server/mcp-grants-hooks.ts b/packages/web/src/app/routes/mcp-server/mcp-grants-hooks.ts new file mode 100644 index 000000000000..0fdef034453f --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/mcp-grants-hooks.ts @@ -0,0 +1,45 @@ +import { ListMcpOAuthGrantsRequestQuery } from '@activepieces/shared'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { t } from 'i18next'; +import { toast } from 'sonner'; + +import { mcpGrantsApi } from './mcp-grants-api'; + +const GRANTS_QUERY_KEY = ['mcp-oauth-grants']; + +export const mcpGrantsQueries = { + useGrants({ request, showErrorDialog }: UseGrantsParams) { + return useQuery({ + queryKey: [...GRANTS_QUERY_KEY, request], + queryFn: () => mcpGrantsApi.list(request), + placeholderData: keepPreviousData, + meta: { showErrorDialog, loadSubsetOptions: {} }, + }); + }, +}; + +export const mcpGrantsMutations = { + useRevoke() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (ids: string[]) => mcpGrantsApi.revoke({ ids }), + onSuccess: () => { + toast.success(t('Access ends within 15 minutes.')); + queryClient.invalidateQueries({ queryKey: GRANTS_QUERY_KEY }); + }, + onError: () => { + toast.error(t('Could not revoke access. Try again.')); + }, + }); + }, +}; + +type UseGrantsParams = { + request: ListMcpOAuthGrantsRequestQuery; + showErrorDialog: boolean; +}; diff --git a/packages/web/src/app/routes/mcp-server/mcp-nav.ts b/packages/web/src/app/routes/mcp-server/mcp-nav.ts index a21a5d01f5f9..de611c5e7ba6 100644 --- a/packages/web/src/app/routes/mcp-server/mcp-nav.ts +++ b/packages/web/src/app/routes/mcp-server/mcp-nav.ts @@ -1,24 +1,36 @@ -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; + +function toTab(value: string | undefined): McpTab { + return value === 'connections' ? value : 'connect'; +} export function useMcpNav(): McpNav { + const { tab } = useParams(); + const navigate = useNavigate(); const [params, setParams] = useSearchParams(); const clientKey = params.get('client'); return { clientKey, + tab: toTab(tab), view: clientKey ? 'client' : params.has('browse') ? 'browse' : 'landing', showLanding: () => setParams({}), showBrowse: () => setParams({ browse: '1' }), showClient: (key: string) => setParams({ client: key }), + showTab: (value: string) => navigate(`/mcp-server/${toTab(value)}`), }; } +export type McpTab = 'connect' | 'connections'; + export type McpView = 'landing' | 'browse' | 'client'; export type McpNav = { + tab: McpTab; view: McpView; clientKey: string | null; showLanding: () => void; showBrowse: () => void; showClient: (key: string) => void; + showTab: (value: string) => void; }; diff --git a/packages/web/src/app/routes/mcp-server/recently-connected.tsx b/packages/web/src/app/routes/mcp-server/recently-connected.tsx new file mode 100644 index 000000000000..17fbf2231c00 --- /dev/null +++ b/packages/web/src/app/routes/mcp-server/recently-connected.tsx @@ -0,0 +1,99 @@ +import { McpOAuthGrant } from '@activepieces/shared'; +import { t } from 'i18next'; +import { Plug } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { formatUtils } from '@/lib/format-utils'; + +import { ClientIcon } from './client-icon'; +import { mcpClientDisplay } from './mcp-client-display'; +import { mcpGrantsQueries } from './mcp-grants-hooks'; +import { useMcpNav } from './mcp-nav'; +import { PageBand } from './page-band'; + +const MAX_SHOWN = 3; + +export function RecentlyConnected() { + const nav = useMcpNav(); + const { data, isLoading, isError } = mcpGrantsQueries.useGrants({ + request: { limit: MAX_SHOWN }, + showErrorDialog: false, + }); + const recent = data?.data ?? []; + + if (isLoading || isError) { + return null; + } + + return ( +
+ + + {t('Recently connected')} + + + {recent.length === 0 ? ( + <> + + + {t( + 'No clients yet โ€” the first one to use the link shows up here.', + )} + + + + ) : ( + <> + {recent.map((row, index) => ( +
+ {index > 0 && } + +
+ ))} + + + )} +
+
+ ); +} + +function ClientChip({ row }: { row: McpOAuthGrant }) { + return ( + + + + {mcpClientDisplay.label({ + key: row.clientKey, + clientName: row.clientName, + })} + + {row.lastUsedAt === null ? ( + + + {t('Waiting for first call')} + + ) : ( + + {formatUtils.formatDateToAgo(new Date(row.lastUsedAt))} + + )} + + ); +} diff --git a/packages/web/src/app/routes/project-routes.tsx b/packages/web/src/app/routes/project-routes.tsx index d5acb19fbbb4..10e0677349b0 100644 --- a/packages/web/src/app/routes/project-routes.tsx +++ b/packages/web/src/app/routes/project-routes.tsx @@ -266,7 +266,7 @@ export const projectRoutes = [ ), }, { - path: '/mcp-server', + path: '/mcp-server/:tab?', element: ( diff --git a/packages/web/src/components/custom/searchable-select.tsx b/packages/web/src/components/custom/searchable-select.tsx index b275ec34e710..29f3305ebc9c 100644 --- a/packages/web/src/components/custom/searchable-select.tsx +++ b/packages/web/src/components/custom/searchable-select.tsx @@ -40,6 +40,8 @@ type SearchableSelectProps = { showRefresh?: boolean; onClose?: () => void; triggerClassName?: string; + /**Widens the popover past the trigger, for compact or inline triggers */ + contentWidth?: string; valuesRendering?: (value: unknown) => React.ReactNode; openState?: { open: boolean; @@ -79,6 +81,7 @@ export const SearchableSelect = ({ showRefresh, onClose, triggerClassName, + contentWidth, valuesRendering, openState: openStateInitializer, refreshOnSearch, @@ -209,8 +212,8 @@ export const SearchableSelect = ({ } }} style={{ - maxWidth: triggerWidth, - minWidth: triggerWidth, + maxWidth: contentWidth ?? triggerWidth, + minWidth: contentWidth ?? triggerWidth, }} className="min-w-full w-full p-0" > diff --git a/packages/web/src/features/agents/agent-card.tsx b/packages/web/src/features/agents/agent-card.tsx index 4cc59b29878d..009facd7230f 100644 --- a/packages/web/src/features/agents/agent-card.tsx +++ b/packages/web/src/features/agents/agent-card.tsx @@ -4,6 +4,7 @@ import { PROJECT_COLOR_PALETTE, } from '@activepieces/shared'; import { t } from 'i18next'; +import { Lock } from 'lucide-react'; import { AgentActionsMenu } from './agent-actions-menu'; import { AgentMark } from './agent-mark'; @@ -59,8 +60,17 @@ export const AgentCard = ({
- - {agent.displayName} + + + {agent.displayName} + + {agent.visibility === AgentVisibility.RESTRICTED && ( + + )} {agent.description ?? t('No description yet')} @@ -73,16 +83,18 @@ export const AgentCard = ({ toolPieceNames={agent.toolPieceNames} />
- {agent.visibility === AgentVisibility.RESTRICTED || - agent.projectIsPrivate ? ( - - ) : ( - agent.projectDisplayName.length > 0 && ( - - ) + {(agent.projectIsPrivate || + agent.projectDisplayName.length > 0) && ( + )}
diff --git a/packages/web/src/features/agents/ai-model/index.tsx b/packages/web/src/features/agents/ai-model/index.tsx index c67ef14543dd..0b9d7321e292 100644 --- a/packages/web/src/features/agents/ai-model/index.tsx +++ b/packages/web/src/features/agents/ai-model/index.tsx @@ -42,6 +42,7 @@ type AIModelSelectorProps = { provider?: string; model?: string; configId?: string; + picked?: 'user' | 'default'; }) => void; }; @@ -131,6 +132,7 @@ export function AIModelSelector({ provider: selectedProvider, model: firstModel, configId: selectedConfigId, + picked: 'default', }); } }, [ @@ -155,6 +157,7 @@ export function AIModelSelector({ provider: selectedProvider, model: fallback, configId: selectedConfigId, + picked: 'default', }); } }, [ @@ -170,7 +173,7 @@ export function AIModelSelector({ setSelectedProvider(provider); setSelectedConfigId(configId); setSelectedModel(undefined); - onChange({ provider, model: undefined, configId }); + onChange({ provider, model: undefined, configId, picked: 'user' }); setProviderOpen(false); }; @@ -180,6 +183,7 @@ export function AIModelSelector({ provider: selectedProvider, model: modelId, configId: selectedConfigId, + picked: 'user', }); setModelOpen(false); }; diff --git a/packages/web/src/features/agents/api/agents.ts b/packages/web/src/features/agents/api/agents.ts index 856164d97fe3..d4ea5398be1d 100644 --- a/packages/web/src/features/agents/api/agents.ts +++ b/packages/web/src/features/agents/api/agents.ts @@ -3,7 +3,6 @@ import { Agent, AgentWithUsage, GetAgentRequest, - MAX_AGENT_PAGE_SIZE, AgentSummary, CreateAgentRequest, DraftAgentRequest, @@ -14,34 +13,10 @@ import { import { api } from '@/lib/api'; -// Bounded so a runaway cursor cannot fire requests forever. Past this the page says it is -// showing a partial list rather than pretending the rest do not exist; a project that really -// holds this many agents wants server-side search instead of loading them all. -const MAX_AGENT_PAGES = 20; - export const agentsApi = { list(request: ListAgentsRequest): Promise> { return api.get>('/v1/agents', request); }, - async listAll( - request: Omit, - ): Promise> { - const collected: AgentSummary[] = []; - let cursor: string | undefined = undefined; - for (let page = 0; page < MAX_AGENT_PAGES; page++) { - const response: SeekPage = await agentsApi.list({ - ...request, - limit: MAX_AGENT_PAGE_SIZE, - ...(cursor === undefined ? {} : { cursor }), - }); - collected.push(...response.data); - if (!response.next) { - return { data: collected, next: null, previous: null }; - } - cursor = response.next; - } - return { data: collected, next: cursor ?? null, previous: null }; - }, get(id: string, request?: GetAgentRequest): Promise { return api.get(`/v1/agents/${id}`, { ...(request?.includeUsage === true ? { includeUsage: 'true' } : {}), diff --git a/packages/web/src/features/agents/hooks/agents-hooks.ts b/packages/web/src/features/agents/hooks/agents-hooks.ts index 81ffbd66636e..d3752d2253b3 100644 --- a/packages/web/src/features/agents/hooks/agents-hooks.ts +++ b/packages/web/src/features/agents/hooks/agents-hooks.ts @@ -1,12 +1,18 @@ import { Agent, + AgentListSort, ApFlagId, CreateAgentRequest, DraftAgentRequest, Permission, UpdateAgentRequest, } from '@activepieces/shared'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { internalErrorToast } from '@/components/ui/sonner'; import { useAuthorization } from '@/hooks/authorization-hooks'; @@ -36,17 +42,37 @@ export const useAgentsNavVisible = (): boolean => { return available && checkAccess(Permission.READ_AGENT); }; +const AGENTS_PAGE_SIZE = 100; + export const agentsQueries = { useAgents: ({ projectId, + search, + sort, enabled = true, }: { projectId?: string; + search?: string; + sort?: AgentListSort; enabled?: boolean; }) => - useQuery({ - queryKey: [AGENTS_KEY, projectId ?? 'all'], - queryFn: () => agentsApi.listAll({ ...(projectId ? { projectId } : {}) }), + useInfiniteQuery({ + queryKey: [ + AGENTS_KEY, + projectId ?? 'all', + search ?? '', + sort ?? 'default', + ], + queryFn: ({ pageParam }) => + agentsApi.list({ + limit: AGENTS_PAGE_SIZE, + ...(projectId ? { projectId } : {}), + ...(search ? { search } : {}), + ...(sort ? { sort } : {}), + ...(pageParam ? { cursor: pageParam } : {}), + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next ?? undefined, enabled, meta: { showErrorDialog: true, loadSubsetOptions: {} }, }), diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx index 2dd0c02e80b9..5d8cc04b088f 100644 --- a/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx +++ b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx @@ -818,9 +818,7 @@ function CodeStep({ authMutations.useVerifyEmailCode({ onSuccess: (data) => { authenticationSession.saveResponse(data, false); - // A brand-new member arrives on the pre-platform onboarding token, so - // there is no project yet: ask their name before building the platform. - if (isNil(data.projectId)) { + if (isNil(data.platformId)) { onNeedsName(); return; } @@ -966,13 +964,16 @@ function ModeSwitch({ } function usePasswordlessAvailable(): boolean { + const { data: codeAuthEnabled } = flagsHooks.useFlag( + ApFlagId.EMAIL_CODE_AUTH_ENABLED, + ); const { data: emailAuthEnabled } = flagsHooks.useFlag( ApFlagId.EMAIL_AUTH_ENABLED, ); const { data: smtpConfigured } = flagsHooks.useFlag( ApFlagId.SMTP_CONFIGURED, ); - return (emailAuthEnabled ?? true) && !!smtpConfigured; + return !!codeAuthEnabled && (emailAuthEnabled ?? true) && !!smtpConfigured; } // Country variants are endless (yahoo.co.uk, hotmail.fr, โ€ฆ), so match the diff --git a/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts b/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts index e9252154f655..af8bf1b7dd1d 100644 --- a/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts @@ -29,8 +29,8 @@ export const aiProviderQueries = { queryFn: () => aiProviderApi.listConfigs(), meta: { showErrorDialog: true, loadSubsetOptions: {} }, }), - useProjectAiProviders: () => { - const projectId = authenticationSession.getProjectId(); + useProjectAiProviders: (forProjectId?: string) => { + const projectId = forProjectId ?? authenticationSession.getProjectId(); return useQuery({ queryKey: aiProviderKeys.forProject(projectId), queryFn: () => @@ -38,9 +38,9 @@ export const aiProviderQueries = { enabled: !isNil(projectId), }); }, - useChatProvider: () => { + useChatProvider: (forProjectId?: string) => { const { data: providers, ...rest } = - aiProviderQueries.useProjectAiProviders(); + aiProviderQueries.useProjectAiProviders(forProjectId); return { ...rest, data: providers?.find((p) => p.enabledForChat) }; }, }; diff --git a/packages/web/test/app/routes/agents/id/leave-guard-arming.test.tsx b/packages/web/test/app/routes/agents/id/leave-guard-arming.test.tsx new file mode 100644 index 000000000000..499fab2cf63e --- /dev/null +++ b/packages/web/test/app/routes/agents/id/leave-guard-arming.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type ModelPick = { + provider?: string; + model?: string; + configId?: string; + picked?: 'user' | 'default'; +}; + +const pendingSaves: { onSuccess?: () => void }[] = []; + +vi.mock('i18next', () => ({ t: (key: string) => key })); +vi.mock('sonner', () => ({ toast: vi.fn() })); +vi.mock('@/app/routes/chat-with-ai/ai-chat-box', () => ({ + AIChatBox: () =>
, +})); +vi.mock('@/app/builder/step-settings/agent-settings/agent-tools', () => ({ + AgentTools: () =>
, +})); +vi.mock('@/hooks/flags-hooks', () => ({ + flagsHooks: { useFlag: () => ({ data: true }) }, +})); +vi.mock('@/hooks/authorization-hooks', () => ({ + useAuthorization: () => ({ checkAccess: () => true }), +})); +vi.mock('@/features/agents', () => ({ + AIModelSelector: ({ onChange }: { onChange: (value: ModelPick) => void }) => ( + <> +