diff --git a/brain/knowledge/connections-auth/ce-authentication.md b/brain/knowledge/connections-auth/ce-authentication.md index e86f046ab6cb..add5156a971b 100644 --- a/brain/knowledge/connections-auth/ce-authentication.md +++ b/brain/knowledge/connections-auth/ce-authentication.md @@ -13,15 +13,21 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - `accessTokenManager`: `generateToken` (7-day JWT), `generateEngineToken`/`generateWorkerToken` (long-lived), `verifyPrincipal` (checks tokenVersion + active status). ### How it works -- Token is a short-lived JWT (7 days) signed with a shared secret. `PrincipalType`: USER, ENGINE, WORKER, SERVICE, UNKNOWN. +- Token is a short-lived JWT (7 days) signed with a shared secret. `PrincipalType`: USER, ENGINE, WORKER, SERVICE, UNKNOWN, ONBOARDING. - Endpoints (all rate-limited via `API_RATE_LIMIT_AUTHN_*`): `POST /v1/authentication/sign-up`, `/sign-in`, `/switch-platform`. -- First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → Platform (`"'s Platform"`) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry. +- First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry. +- **`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 returns an ONBOARDING response, and the member names the platform themselves at `/create-platform`. `getPreferredPlatformId` returns null on every non-Cloud edition. There is no `"'s Platform"` autoname in production; that string lives only in `dev-seeds.ts`. +- **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, 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`. +- **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. ### Gotchas - Email-auth checks and domain allow-listing guards are **skipped on Community** edition. - OTP verification only sent on Cloud production; CE/EE and Cloud-dev (`AP_ENVIRONMENT=development`) auto-verify the identity. - Telemetry PII (email/name) sent only on Cloud; CE/EE send non-PII fields (`pickTelemetryPii`). Sign-in telemetry covers password sign-in only, not SSO. - Sessions are invalidated by rotating `tokenVersion` on `UserIdentity`. +- **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`. +- **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. ### Key files Entry point: `authenticationService`, a log-taking factory called per request from `authentication.controller.ts`, registered as `authenticationModule` in `app.ts`. diff --git a/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md b/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md index 97da9316fd98..1c0df533c6ce 100644 --- a/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md +++ b/brain/knowledge/connections-auth/ee-authentication-sso-rbac.md @@ -13,12 +13,17 @@ Enterprise auth layer extending CE with SAML 2.0 SSO, Google/GitHub federated OA ### How it works - **SAML SSO**: `POST /v1/authn/saml/login` returns IdP redirect; IdP POSTs assertion to ACS `POST /v1/authn/saml/acs`; service parses email/name → federatedAuthn → JWT. Gated by `platform.plan.ssoEnabled`. - **Federated OAuth (Google/GitHub)**: `/v1/authn/federated/login` returns redirect URL; `/v1/authn/federated/claim` exchanges code → JWT. Redirects always use `FRONTEND_URL` (no custom domain). -- **OTP** (`EMAIL_VERIFICATION`, `PASSWORD_RESET`): per-type expiry (`OTP_EXPIRATION_MS` in `otp-service.ts`: 24h verification, 10-min reset); states PENDING/CONFIRMED. Resend re-delivers the existing pending OTP value WITHOUT touching the row — expiry stays anchored to the value's creation, so resends cannot extend a (possibly compromised) OTP's lifetime; a new value is generated only once the OTP is expired or confirmed (GIT-1733: the old early-return made resend a silent 204 no-op). Known bounded edge: a resend requested just before expiry delivers a short-lived link; the next resend regenerates. +- **OTP** (`EMAIL_VERIFICATION`, `PASSWORD_RESET`, `EMAIL_LOGIN`): per-type expiry (`OTP_EXPIRATION_MS` in `otp-service.ts`: 24h verification, 10-min reset, 10-min login); states PENDING/CONFIRMED; one row per `(identityId, type)`, DB-enforced. Resend re-delivers the existing pending value WITHOUT touching the row — expiry stays anchored to the value's creation, so resends cannot extend a (possibly compromised) OTP's lifetime; a new value is generated only once the old one is expired or spent (GIT-1733: the old early-return made resend a silent 204 no-op). The first two types carry a `randomUUID()` delivered as a link; `EMAIL_LOGIN` carries a 6-digit code the member types, and its row counts `attempts` so it dies after five wrong guesses — counted in raw SQL for the same reason resend leaves the row alone, since touching `updated` would buy the guesser another window. Known bounded edge: a resend requested just before expiry delivers a short-lived link; the next resend regenerates. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md). - **Enterprise local auth**: `verifyEmail` (confirms OTP → sets verified), `resetPassword` (confirms OTP → updates hash), both audit-logged. - **RBAC**: `assertPrincipalAccessToProject({principal, permission, projectId})` and `assertUserHasPermissionToFlow` (maps FlowOperationType → Permission). Authorization hooks: `platformMustHaveFeatureEnabled` (402 FEATURE_DISABLED), `projectMustBeTeamType`, `platformMustBeOwnedByCurrentUser`. ### Gotchas -- CE gets OTP flows + RBAC base types; **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 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**. +- **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. +- **One constant is both the expiry and the resend suppression.** `TEN_MINUTES` gates `confirm`'s freshness check and `createAndSend`'s "an OTP already exists" early return, so before this work a resend was impossible until the current credential expired, and the request endpoint still answered 204. Resend now re-delivers the existing value without touching `updated`. +- **`email-service.ts` is not exhaustive over `OtpType`.** `frontendPath` is a literal keyed by only two members but indexed by the whole union, so adding a member is a compile break; its sibling `otpToTemplate` is typed `Record`, which type-checks and hands `undefined` to the sender at runtime instead. - SSO settings page wrapped in `LockedFeatureGuard` keyed on `ssoEnabled`. - Managed auth gated separately by `embeddingEnabled` (signing keys). See the Managed Auth page. - The authn rate limiter (`core/security/rate-limit.ts`) is registered with `global: false` — it protects NOTHING by default. Every public endpoint that sends email or does auth work must opt in per-route via `config.rateLimit` (see `authentication.controller.ts` / `otp-controller.ts` for the `API_RATE_LIMIT_AUTHN_*` pattern). diff --git a/brain/knowledge/connections-auth/index.md b/brain/knowledge/connections-auth/index.md index 872017ffd6a7..279f2a8f99ed 100644 --- a/brain/knowledge/connections-auth/index.md +++ b/brain/knowledge/connections-auth/index.md @@ -25,11 +25,11 @@ Platform owners register their own OAuth client_id/secret per piece so connectio ### CE Authentication -User identity, sign-in, JWT sessions. `UserIdentity` = canonical email+password+provider (one per email, shared across platforms); `User` = platform-scoped membership. First sign-up auto-creates a Platform + personal Project + ADMIN user. JWT is 7-day, signed with a shared secret; rotating `tokenVersion` on UserIdentity invalidates all sessions. `accessTokenManager` also mints long-lived engine/worker tokens. Endpoints: `/v1/authentication/sign-up|sign-in|switch-platform`. PrincipalTypes: USER/ENGINE/WORKER/SERVICE/UNKNOWN. +User identity, sign-in, JWT sessions. `UserIdentity` = canonical email+password+provider (one per email, shared across platforms); `User` = platform-scoped membership. First sign-up auto-creates a Platform + personal Project + ADMIN user. JWT is 7-day, signed with a shared secret; rotating `tokenVersion` on UserIdentity invalidates all sessions. `accessTokenManager` also mints long-lived engine/worker tokens. Endpoints: `/v1/authentication/sign-up|sign-in|switch-platform`. PrincipalTypes: USER/ENGINE/WORKER/SERVICE/UNKNOWN/ONBOARDING (the last is the pre-platform session that can only call `POST /v1/platforms`). ### 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) lives here but is available in CE too. +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. ### 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 new file mode 100644 index 000000000000..d0e5962a6ceb --- /dev/null +++ b/brain/knowledge/decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md @@ -0,0 +1,43 @@ +--- +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. + +## 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. + +A vibe-coded branch built this as new machinery and regressed three properties in the process, which is what forced the calls below. + +## Why + +**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. + +**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. + +**Brute force is capped per credential, not per IP.** A 6-digit code is a 10^6 space, and `confirm` compared plaintext with no attempt counter while the request endpoint carried no rate-limit config. Rate limiting alone does not bound a distributed attacker, so the row now carries an `attempts` counter and dies on the fifth wrong guess. Rate limits go on both endpoints as well, but the counter is what makes the budget five guesses per issued code regardless of how the requests are spread. + +**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. + +**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. + +## Consequences +An identity is created before ownership is proven, the member's name is a guess, and the resend window behaves differently. + +- **`firstName` is derived from the email local part, knowingly as a placeholder.** A code sign-up asks for no name, so the local part is capitalised and stored. This is wrong for shared and role addresses (`info@` becomes "Info") and for single-letter local parts, and the guess persists as the member's real name. Accepted deliberately to keep the first cut shippable, with the derivation to be replaced later rather than left to rot. Nothing else depends on it: platform naming derives from the local part directly, not from `firstName`. + +- `requestCode` creates a `UserIdentity` with `verified: false` and a random password when none exists, because the `otp` row needs an `identityId` to hang on. So an unauthenticated endpoint can create rows. `ApFlagId.USER_CREATED` is deliberately NOT set until a code verifies, and both endpoints are rate limited. Never-verified identities need a prune story. +- `confirm` now deletes the row instead of marking it `CONFIRMED`. This is also a bug fix for the two existing types: `updated` is an `updateDate` column, so marking the row refreshed it and the ten-minute guard then blocked that identity from requesting another code for ten minutes after a successful use. +- **The attempt counter is incremented with a bare `UPDATE ... SET attempts = attempts + 1 RETURNING attempts`, not through the repository.** Two reasons, both found in review. TypeORM's `update` touches `updated`, the very column the expiry and resend-suppression checks read, so counting a wrong guess would have extended the credential's life by ten minutes per guess. And a read-modify-write increment lets concurrent verifies write the same value, so the five-guess budget would never be reached under a parallel attack. +- **`OtpState.CONFIRMED` is now written by nothing.** It survives only as the `otpIsPending` read, which still correctly rejects legacy rows a previous build left CONFIRMED. Retire the member once those rows have aged past the ten-minute window. +- **The public `POST /v1/otp` route must never accept `EMAIL_LOGIN`.** Adding the member to `OtpType` silently widened that unauthenticated, unthrottled, unguarded route into a way to email anyone a working sign-in code, so `CreateOtpRequestBody` now narrows `type` to the two link types. +- **`verifyCode` re-asserts the platform auth policy on the resolved platform.** On Cloud, `getPlatformIdForRequest` returns null for unauthenticated requests, so the request-scoped branch never runs there; without a second assert an email code would sign a member into a platform that had deliberately disabled email auth or removed their domain from the allow-list. Deliberately not asserted at request time, since surfacing those errors per address would rebuild the existence oracle this design closes. +- An un-invited member on an invitation-only instance is told "check your email" and no mail arrives. Accepted in exchange for closing the invitation oracle. +- Adding an `OtpType` member is a forced compile break in `email-service.ts`, whose `frontendPath` is a two-key literal indexed by the full union. Its sibling `otpToTemplate` is typed `Record`, so it type-checks while handing `undefined` to the sender at runtime. Both are replaced by one exhaustive switch. diff --git a/bun.lock b/bun.lock index 038b6187a90c..207d15bcd621 100644 --- a/bun.lock +++ b/bun.lock @@ -10684,6 +10684,7 @@ "dayjs": "1.11.9", "decompress": "4.2.1", "deep-equal": "2.2.2", + "disposable-email-domains": "1.0.62", "dotenv": "17.2.3", "eslint-scope": "7.2.2", "fast-xml-parser": "^5.5.6", @@ -10964,6 +10965,7 @@ "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "3.0.5", "i18next-icu": "2.3.0", + "input-otp": "1.4.2", "jszip": "3.10.1", "jwt-decode": "4.0.0", "lucide-react": "0.576.0", @@ -15214,6 +15216,8 @@ "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="], + "disposable-email-domains": ["disposable-email-domains@1.0.62", "", {}, "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ=="], + "docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="], "dockerode": ["dockerode@4.0.7", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.6", "protobufjs": "^7.3.2", "tar-fs": "~2.1.2", "uuid": "^10.0.0" } }, "sha512-R+rgrSRTRdU5mH14PZTCPZtW/zw3HDWNTS/1ZAQpL/5Upe/ye5K9WQkIysu4wBoiMwKynsz0a8qWuGsHgEvSAA=="], @@ -15858,6 +15862,8 @@ "inline-style-prefixer": ["inline-style-prefixer@7.0.1", "", { "dependencies": { "css-in-js-utils": "^3.1.0" } }, "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw=="], + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + "inquirer": ["inquirer@8.2.7", "", { "dependencies": { "@inquirer/external-editor": "^1.0.0", "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^6.0.1" } }, "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA=="], "intercom-client": ["intercom-client@6.2.0", "", { "dependencies": { "form-data": "^4.0.0", "formdata-node": "^6.0.3", "js-base64": "3.7.7", "node-fetch": "^2.7.0", "qs": "^6.13.1", "readable-stream": "^4.5.2", "url-join": "4.0.1" } }, "sha512-ta9UB6twCk6b4OLiC1HUtB0NgDCpHLtiXfWSV8QjAUmXzjq1aBd4axKW6CgPiEnBhrkTdeJ4u0Mvx/MRzXlVWw=="], diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index 16013d8d6263..83b2aa16145e 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -60,6 +60,10 @@ Nothing on upgrade. Re-check any flow or API integration that filters a Date col ### What has changed? +#### Disposable email addresses can no longer sign up + +Sign-up now refuses addresses from throwaway providers such as `mailinator.com` and `guerrillamail.com`, on both the emailed-code flow and the password form. Federated sign-in (Google, SAML, JWT), managed authentication and SCIM provisioning are unaffected, since those addresses come from an identity provider you already trust. + #### Plans and credits are now managed by our billing service Two license-key endpoints are removed: `GET /v1/license-keys/:licenseKey` and `POST /v1/license-keys/verify`. Applying a key is now `POST /v1/platform-billing/activate`. @@ -76,6 +80,7 @@ When a platform has no credits left, new production runs are recorded with the ` In the AI piece, picking the Activepieces-provided AI now lists only the named tiers (Fast, Expert, Heavy) rather than the full upstream model catalogue. Existing steps keep running on the model they already have, but that model no longer appears in the dropdown, so re-saving the step moves it onto one of the tiers. ### Do you need to take action? +- Only if your members sign up with addresses from a disposable email provider. Set `AP_ALLOW_DISPOSABLE_EMAILS=true` to keep accepting them. - Only if you run the Enterprise edition without a license key. Enter your key so your contracted limits apply instead of the free plan's. - Only if you call `GET /v1/license-keys/:licenseKey` or `POST /v1/license-keys/verify`. Both are removed — use `POST /v1/platform-billing/activate` instead. - Only if you read `SHOW_BILLING_PAGE`, `CAN_BUY_ACTIVE_FLOWS`, `CAN_BUY_AI_CREDITS` or `SHOW_BILLING_LIMITS_ON_SIDEBAR` from `GET /v1/flags`. They no longer exist. diff --git a/docs/install/reference/environment-variables.mdx b/docs/install/reference/environment-variables.mdx index 1b78cb7942c8..cee703b8961f 100644 --- a/docs/install/reference/environment-variables.mdx +++ b/docs/install/reference/environment-variables.mdx @@ -195,6 +195,22 @@ S3-compatible bucket. --- +### Sign-up protection + +Controls on who may create an account. Both default to the safe behaviour with +no configuration: disposable addresses are refused, and no challenge is served +until you supply Turnstile keys. + +| Variable | Description | Default | +|---|---|---| +| `AP_ALLOW_DISPOSABLE_EMAILS` | Accept addresses from throwaway email providers. | `false` | +| `AP_TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key. Public; served to the sign-in page. | `None` | +| `AP_TURNSTILE_SECRET_KEY` | Cloudflare Turnstile secret key, used to verify a solved challenge. | `None` | + +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. + ### 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 d7d6847ba387..d974035a9f17 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.133.0", + "version": "0.136.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/index.ts b/packages/core/shared/src/index.ts index 9db20bad86fc..6480cfb3dfad 100755 --- a/packages/core/shared/src/index.ts +++ b/packages/core/shared/src/index.ts @@ -3,6 +3,7 @@ export * from './lib/core/common/telemetry-pii' export * from './lib/core/authentication/dto/authentication-response' export * from './lib/core/authentication/dto/sign-up-request' export * from './lib/core/authentication/dto/sign-in-request' +export * from './lib/core/authentication/dto/passwordless-request' export * from './lib/core/authentication/model/principal-type' export * from './lib/core/authentication/model/principal' export * from './lib/core/authentication/user-identity' diff --git a/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts b/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts new file mode 100644 index 000000000000..236a6919f86f --- /dev/null +++ b/packages/core/shared/src/lib/core/authentication/dto/passwordless-request.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' +import { EmailType } from '../../user/user' + +export const MAX_FULL_NAME_LENGTH = 100 +export const MAX_CAPTCHA_TOKEN_LENGTH = 2048 + +export const RequestEmailCodeRequest = z.object({ + email: EmailType, + captchaToken: z.string().trim().min(1).max(MAX_CAPTCHA_TOKEN_LENGTH).optional(), +}) + +export type RequestEmailCodeRequest = z.infer + +export const VerifyEmailCodeRequest = z.object({ + email: EmailType, + code: z.string().trim().min(1), +}) + +export type VerifyEmailCodeRequest = z.infer + +export const CompleteSignUpRequest = z.object({ + fullName: z.string().trim().min(1).max(MAX_FULL_NAME_LENGTH), +}) + +export type CompleteSignUpRequest = z.infer diff --git a/packages/core/shared/src/lib/core/authentication/dto/sign-up-request.ts b/packages/core/shared/src/lib/core/authentication/dto/sign-up-request.ts index a29ac76e8efe..d46862de1170 100755 --- a/packages/core/shared/src/lib/core/authentication/dto/sign-up-request.ts +++ b/packages/core/shared/src/lib/core/authentication/dto/sign-up-request.ts @@ -1,6 +1,7 @@ import { ApId, SAFE_STRING_PATTERN } from '@activepieces/core-utils' import { z } from 'zod' import { EmailType, PasswordType } from '../../user/user' +import { MAX_CAPTCHA_TOKEN_LENGTH } from './passwordless-request' export const SignUpRequest = z.object({ email: EmailType, @@ -9,6 +10,7 @@ export const SignUpRequest = z.object({ lastName: z.string().regex(new RegExp(SAFE_STRING_PATTERN)), trackEvents: z.boolean(), newsLetter: z.boolean(), + captchaToken: z.string().trim().min(1).max(MAX_CAPTCHA_TOKEN_LENGTH).optional(), }) export type SignUpRequest = z.infer diff --git a/packages/core/shared/src/lib/core/common/telemetry.ts b/packages/core/shared/src/lib/core/common/telemetry.ts index d4c880232f7b..bbf7faebb736 100644 --- a/packages/core/shared/src/lib/core/common/telemetry.ts +++ b/packages/core/shared/src/lib/core/common/telemetry.ts @@ -35,6 +35,24 @@ type SignedUp = { projectId: ProjectId } +type EmailCodeRequested = { + isNewIdentity: boolean +} + +type EmailCodeVerified = { + needsNameStep: boolean +} + +type EmailCodeRejected = { + errorCode: string +} + +type EmailCodeResendRequested = Record + +type CaptchaUnavailable = { + surface: string +} + type QuotaAlert = { percentageUsed: number } @@ -188,6 +206,11 @@ type SignedIn = { } export enum TelemetryEventName { SIGNED_UP = 'signed.up', + EMAIL_CODE_REQUESTED = 'email.code.requested', + EMAIL_CODE_VERIFIED = 'email.code.verified', + EMAIL_CODE_REJECTED = 'email.code.rejected', + EMAIL_CODE_RESEND_REQUESTED = 'email.code.resend.requested', + CAPTCHA_UNAVAILABLE = 'captcha.unavailable', QUOTA_ALERT = 'quota.alert', REQUEST_TRIAL_CLICKED = 'request.trial.clicked', REQUEST_TRIAL_SUBMITTED = 'request.trial.submitted', @@ -239,6 +262,26 @@ type BaseTelemetryEvent = { export type TelemetryEvent = | BaseTelemetryEvent + | BaseTelemetryEvent< + TelemetryEventName.EMAIL_CODE_REQUESTED, + EmailCodeRequested + > + | BaseTelemetryEvent< + TelemetryEventName.EMAIL_CODE_VERIFIED, + EmailCodeVerified + > + | BaseTelemetryEvent< + TelemetryEventName.EMAIL_CODE_REJECTED, + EmailCodeRejected + > + | BaseTelemetryEvent< + TelemetryEventName.EMAIL_CODE_RESEND_REQUESTED, + EmailCodeResendRequested + > + | BaseTelemetryEvent< + TelemetryEventName.CAPTCHA_UNAVAILABLE, + CaptchaUnavailable + > | BaseTelemetryEvent | BaseTelemetryEvent< TelemetryEventName.REQUEST_TRIAL_CLICKED, diff --git a/packages/core/shared/src/lib/core/flag/flag.ts b/packages/core/shared/src/lib/core/flag/flag.ts index f4fe42a173f6..90d8a0bfff8e 100755 --- a/packages/core/shared/src/lib/core/flag/flag.ts +++ b/packages/core/shared/src/lib/core/flag/flag.ts @@ -63,5 +63,6 @@ export enum ApFlagId { PROJECT_RATE_LIMITER_ENABLED = 'PROJECT_RATE_LIMITER_ENABLED', DEFAULT_CONCURRENT_JOBS_LIMIT = 'DEFAULT_CONCURRENT_JOBS_LIMIT', SMTP_CONFIGURED = 'SMTP_CONFIGURED', + TURNSTILE_SITE_KEY = 'TURNSTILE_SITE_KEY', PGVECTOR_AVAILABLE = 'PGVECTOR_AVAILABLE', } diff --git a/packages/core/shared/src/lib/ee/agent/agent.ts b/packages/core/shared/src/lib/ee/agent/agent.ts index e87f83d2eb96..044035791831 100644 --- a/packages/core/shared/src/lib/ee/agent/agent.ts +++ b/packages/core/shared/src/lib/ee/agent/agent.ts @@ -10,6 +10,10 @@ 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_NAME_LENGTH = 200 +const MAX_AGENT_DESCRIPTION_LENGTH = 2_000 +const MAX_AGENT_CONFIG_BYTES = 128_000 +const MAX_DRAFT_PROMPT_LENGTH = 2_000 const DEFAULT_AGENT_MAX_STEPS = 20 enum AgentVisibility { @@ -35,10 +39,14 @@ enum AgentIcon { const AgentConfig = z.object({ instructions: z.string().max(MAX_AGENT_TEXT_LENGTH), provider: Nullable(z.enum(AIProviderName)), - modelName: Nullable(z.string()), + modelName: Nullable(z.string().max(MAX_AGENT_NAME_LENGTH)), maxSteps: z.number().int().positive().max(MAX_AGENT_STEP_BUDGET).default(DEFAULT_AGENT_MAX_STEPS), tools: z.array(AgentTool).max(MAX_AGENT_TOOLS).default([]), structuredOutput: z.array(AgentOutputField).max(MAX_AGENT_OUTPUT_FIELDS).default([]), +}).superRefine((config, ctx) => { + if (JSON.stringify(config).length > MAX_AGENT_CONFIG_BYTES) { + ctx.addIssue({ code: 'custom', message: formErrors.agentConfigTooLarge }) + } }) const Agent = z.object({ @@ -56,6 +64,8 @@ const Agent = z.object({ published: Nullable(AgentConfig), }) +const AgentSummary = Agent.omit({ draft: true, published: true }) + const CreateAgentRequest = z.object({ projectId: ApId, displayName: z.string().min(1, formErrors.required), @@ -69,22 +79,50 @@ const CreateAgentRequest = z.object({ const UpdateAgentRequest = CreateAgentRequest.omit({ projectId: true }).partial() +const DraftAgentResponse = z.object({ + displayName: z.string().min(1, formErrors.required).max(MAX_AGENT_NAME_LENGTH), + description: z.string().max(MAX_AGENT_NAME_LENGTH), + icon: z.enum(AgentIcon).catch(AgentIcon.BOT), + color: z.enum(ColorName).catch(ColorName.PURPLE), + instructions: z.string().min(1, formErrors.required).max(MAX_AGENT_TEXT_LENGTH), +}) + +const AgentTemplate = DraftAgentResponse.extend({ id: z.string() }) + +const DraftAgentRequest = z.object({ + projectId: ApId, + prompt: z.string().min(1, formErrors.required).max(MAX_DRAFT_PROMPT_LENGTH), +}) + const ListAgentsRequest = z.object({ projectId: z.optional(ApId), cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(MAX_AGENT_PAGE_SIZE).optional(), }) +const agentUtils = { + isPublishable: (config: AgentConfig): boolean => (config.instructions ?? '').trim().length > 0, +} + export { Agent, + AgentSummary, + agentUtils, AgentConfig, AgentIcon, AgentVisibility, + AgentTemplate, CreateAgentRequest, DEFAULT_AGENT_MAX_STEPS, + DraftAgentRequest, + DraftAgentResponse, ListAgentsRequest, MAX_AGENT_OUTPUT_FIELDS, + MAX_AGENT_CONFIG_BYTES, + MAX_AGENT_DESCRIPTION_LENGTH, + MAX_AGENT_NAME_LENGTH, MAX_AGENT_PAGE_SIZE, + MAX_DRAFT_PROMPT_LENGTH, MAX_AGENT_SHARED_MEMBERS, MAX_AGENT_STEP_BUDGET, MAX_AGENT_TEXT_LENGTH, @@ -93,7 +131,11 @@ export { } export type Agent = z.infer +export type AgentSummary = z.infer export type AgentConfig = z.infer +export type AgentTemplate = z.infer export type CreateAgentRequest = z.infer +export type DraftAgentRequest = z.infer +export type DraftAgentResponse = z.infer export type ListAgentsRequest = z.infer export type UpdateAgentRequest = z.infer diff --git a/packages/core/shared/src/lib/ee/audit-events/index.ts b/packages/core/shared/src/lib/ee/audit-events/index.ts index 5e2bc10df1a5..e7ad0fc19aa4 100644 --- a/packages/core/shared/src/lib/ee/audit-events/index.ts +++ b/packages/core/shared/src/lib/ee/audit-events/index.ts @@ -37,6 +37,8 @@ export enum ApplicationEventName { AGENT_CREATED = 'agent.created', AGENT_UPDATED = 'agent.updated', AGENT_DELETED = 'agent.deleted', + AGENT_PUBLISHED = 'agent.published', + AGENT_UNPUBLISHED = 'agent.unpublished', VARIABLE_UPSERTED = 'variable.upserted', VARIABLE_DELETED = 'variable.deleted', VARIABLE_VALUE_REVEALED = 'variable.value.revealed', @@ -106,6 +108,8 @@ const AgentEventData = z.object({ agent: z.object({ id: z.string(), displayName: z.string(), + publishedDigest: z.string().optional(), + publishedToolNames: z.array(z.string()).optional(), }), }) @@ -115,6 +119,8 @@ export const AgentAuditEvent = z.object({ z.literal(ApplicationEventName.AGENT_CREATED), z.literal(ApplicationEventName.AGENT_UPDATED), z.literal(ApplicationEventName.AGENT_DELETED), + z.literal(ApplicationEventName.AGENT_PUBLISHED), + z.literal(ApplicationEventName.AGENT_UNPUBLISHED), ]), data: AgentEventData, }) @@ -562,6 +568,10 @@ export function summarizeApplicationEvent(event: ApplicationEvent) { return `Agent ${event.data.agent.displayName} is updated` case ApplicationEventName.AGENT_DELETED: return `Agent ${event.data.agent.displayName} is deleted` + case ApplicationEventName.AGENT_PUBLISHED: + return `Agent ${event.data.agent.displayName} is published` + case ApplicationEventName.AGENT_UNPUBLISHED: + return `Agent ${event.data.agent.displayName} is taken offline` case ApplicationEventName.VARIABLE_UPSERTED: return `Variable ${event.data.variable.name} is created or updated` case ApplicationEventName.VARIABLE_DELETED: diff --git a/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts b/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts index ec0c0999233c..b95c4099debb 100644 --- a/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts +++ b/packages/core/shared/src/lib/ee/audit-events/mock-event-builder.ts @@ -171,7 +171,9 @@ export const buildMockEvent = ({ event, platformId, projectId }: BuildMockEventP } case ApplicationEventName.AGENT_CREATED: case ApplicationEventName.AGENT_UPDATED: - case ApplicationEventName.AGENT_DELETED: { + case ApplicationEventName.AGENT_DELETED: + case ApplicationEventName.AGENT_PUBLISHED: + case ApplicationEventName.AGENT_UNPUBLISHED: { const mock: AgentAuditEvent = { ...baseEnvelope, action: event, diff --git a/packages/core/shared/src/lib/ee/otp/otp-model.ts b/packages/core/shared/src/lib/ee/otp/otp-model.ts index daded13e68a8..c9175a17ffd0 100644 --- a/packages/core/shared/src/lib/ee/otp/otp-model.ts +++ b/packages/core/shared/src/lib/ee/otp/otp-model.ts @@ -15,6 +15,7 @@ export const OtpModel = z.object({ identityId: ApId, value: z.string(), state: z.nativeEnum(OtpState), + attempts: z.number(), }) export type OtpModel = z.infer diff --git a/packages/core/shared/src/lib/ee/otp/otp-requests.ts b/packages/core/shared/src/lib/ee/otp/otp-requests.ts index 66d8ada63649..3edaba0eceea 100644 --- a/packages/core/shared/src/lib/ee/otp/otp-requests.ts +++ b/packages/core/shared/src/lib/ee/otp/otp-requests.ts @@ -4,7 +4,7 @@ import { OtpType } from './otp-type' export const CreateOtpRequestBody = z.object({ email: z.string(), - type: z.nativeEnum(OtpType), + type: z.enum([OtpType.EMAIL_VERIFICATION, OtpType.PASSWORD_RESET]), }) export type CreateOtpRequestBody = z.infer diff --git a/packages/core/shared/src/lib/ee/otp/otp-type.ts b/packages/core/shared/src/lib/ee/otp/otp-type.ts index 92fe0aa9e774..ccbb4ef895f7 100644 --- a/packages/core/shared/src/lib/ee/otp/otp-type.ts +++ b/packages/core/shared/src/lib/ee/otp/otp-type.ts @@ -1,4 +1,5 @@ export enum OtpType { EMAIL_VERIFICATION = 'EMAIL_VERIFICATION', PASSWORD_RESET = 'PASSWORD_RESET', + EMAIL_LOGIN = 'EMAIL_LOGIN', } diff --git a/packages/core/shared/src/lib/form-errors.ts b/packages/core/shared/src/lib/form-errors.ts index b4069f5bb449..3169be2b40e6 100644 --- a/packages/core/shared/src/lib/form-errors.ts +++ b/packages/core/shared/src/lib/form-errors.ts @@ -6,4 +6,5 @@ export const formErrors = { invalidExternalId: 'invalidExternalId', invalidFileName: 'invalidFileName', messageRequiresContentOrFiles: 'messageRequiresContentOrFiles', + agentConfigTooLarge: 'agentConfigTooLarge', } as const diff --git a/packages/server/api/package.json b/packages/server/api/package.json index 860d63ff4139..d033eb398ce9 100644 --- a/packages/server/api/package.json +++ b/packages/server/api/package.json @@ -5,6 +5,10 @@ "type": "commonjs", "dependencies": { "@1password/sdk": "0.4.0", + "@activepieces/core-execution": "workspace:*", + "@activepieces/core-formula": "workspace:*", + "@activepieces/core-piece-types": "workspace:*", + "@activepieces/core-utils": "workspace:*", "@activepieces/engine": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", @@ -12,10 +16,10 @@ "@activepieces/shared": "workspace:*", "@ai-sdk/amazon-bedrock": "5.0.38", "@ai-sdk/anthropic": "4.0.25", - "@ai-sdk/mcp": "2.0.20", "@ai-sdk/azure": "4.0.26", "@ai-sdk/google": "4.0.29", "@ai-sdk/google-vertex": "5.0.36", + "@ai-sdk/mcp": "2.0.20", "@ai-sdk/openai": "4.0.25", "@ai-sdk/openai-compatible": "3.0.18", "@ai-sdk/provider": "4.0.4", @@ -41,7 +45,6 @@ "@modelcontextprotocol/sdk": "1.27.1", "@openrouter/ai-sdk-provider": "3.0.0", "@openrouter/sdk": "0.2.9", - "posthog-node": "5.38.5", "@sentry/node": "7.120.0", "@smithy/node-http-handler": "4.4.14", "@socket.io/redis-adapter": "8.3.0", @@ -62,6 +65,7 @@ "dayjs": "1.11.9", "decompress": "4.2.1", "deep-equal": "2.2.2", + "disposable-email-domains": "1.0.62", "dotenv": "17.2.3", "eslint-scope": "7.2.2", "fast-xml-parser": "^5.5.6", @@ -87,10 +91,11 @@ "object-sizeof": "2.6.3", "p-limit": "2.3.0", "pg": "8.11.3", - "request-filtering-agent": "3.2.0", + "posthog-node": "5.38.5", "qs": "6.15.2", "redis-memory-server": "0.15.0", "redlock": "5.0.0-beta.2", + "request-filtering-agent": "3.2.0", "samlify": "2.13.0", "semver": "7.6.0", "simple-git": "3.36.0", @@ -103,11 +108,7 @@ "typeorm": "0.3.31", "typeorm-pglite": "0.3.2", "unpdf": "1.4.0", - "zod": "4.3.6", - "@activepieces/core-utils": "workspace:*", - "@activepieces/core-formula": "workspace:*", - "@activepieces/core-piece-types": "workspace:*", - "@activepieces/core-execution": "workspace:*" + "zod": "4.3.6" }, "devDependencies": { "@activepieces/piece-facebook-leads": "workspace:*", diff --git a/packages/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index f9dab5862fc4..8df942952172 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -18,6 +18,7 @@ import { setPlatformOAuthService } from './app-connection/app-connection-service 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 { canaryRoutingMiddleware } from './core/canary/canary-routing.middleware' import { collaborativeModule } from './core/collaborative/collaborative.module' import { oidcModule } from './core/security/oidc/oidc.module' @@ -36,7 +37,6 @@ import { appSumoModule } from './ee/appsumo/appsumo.module' import { auditEventModule } from './ee/audit-logs/audit-event-module' import { enterpriseLocalAuthnModule } from './ee/authentication/enterprise-local-authn/enterprise-local-authn-module' import { federatedAuthModule } from './ee/authentication/federated-authn/federated-authn-module' -import { otpModule } from './ee/authentication/otp/otp-module' import { rbacMiddleware } from './ee/authentication/project-role/rbac-middleware' import { authnSsoSamlModule } from './ee/authentication/saml-authn/authn-sso-saml-module' import { billingUsageReportModule } from './ee/billing-usage-report/billing-usage-report-module' @@ -384,6 +384,7 @@ export const setupApp = async (app: FastifyInstance): Promise = case ApEdition.COMMUNITY: await app.register(platformProjectModule) await app.register(communityPiecesModule) + await app.register(otpModule) break } diff --git a/packages/server/api/src/app/authentication/authentication.controller.ts b/packages/server/api/src/app/authentication/authentication.controller.ts index ca1ed4b1e9b4..23222f205f5e 100644 --- a/packages/server/api/src/app/authentication/authentication.controller.ts +++ b/packages/server/api/src/app/authentication/authentication.controller.ts @@ -1,8 +1,10 @@ import { isNil } from '@activepieces/core-utils' -import { ApplicationEventName, PrincipalType, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider } from '@activepieces/shared' -import { RateLimitOptions } from '@fastify/rate-limit' +import { ApplicationEventName, CompleteSignUpRequest, PrincipalType, RequestEmailCodeRequest, SignInRequest, SignUpRequest, SwitchPlatformRequest, TelemetryEventName, UserIdentityProvider, VerifyEmailCodeRequest } 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 { applicationEvents } from '../helper/application-events' import { networkUtils } from '../helper/network-utils' import { rejectedPromiseHandler } from '../helper/promise-handler' @@ -12,6 +14,8 @@ import { telemetry } from '../helper/telemetry.utils' import { platformUtils } from '../platform/platform.utils' import { userService } from '../user/user-service' import { authenticationService } from './authentication.service' +import { turnstile } from './lib/turnstile' +import { passwordlessAuthService } from './passwordless-auth.service' export const authenticationController: FastifyPluginAsyncZod = async ( app, @@ -19,6 +23,11 @@ export const authenticationController: FastifyPluginAsyncZod = async ( app.post('/sign-up', SignUpRequestOptions, async (request) => { const platformId = await platformUtils.getPlatformIdForRequest(request) + await turnstile.assertSolved({ + token: request.body.captchaToken, + remoteIp: clientIp(request), + log: request.log, + }) const signUpResponse = await authenticationService(request.log).signUp({ ...request.body, provider: UserIdentityProvider.EMAIL, @@ -73,6 +82,68 @@ 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, + fullName: request.body.fullName, + }) + + if (signedUp && !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_UP, + data: {}, + }) + } + + return response + }) + app.post('/switch-platform', SwitchPlatformRequestOptions, async (request) => { const user = await userService(request.log).getOneOrFail({ id: request.principal.id }) return authenticationService(request.log).switchPlatform({ @@ -83,20 +154,12 @@ export const authenticationController: FastifyPluginAsyncZod = async ( } -const rateLimitOptions: RateLimitOptions = { - max: Number.parseInt( - system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_MAX), - 10, - ), - timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), -} - const SwitchPlatformRequestOptions = { config: { security: securityAccess.publicPlatform([PrincipalType.USER]), - rateLimit: rateLimitOptions, + rateLimit: authnRateLimit, }, schema: { body: SwitchPlatformRequest, @@ -106,17 +169,51 @@ const SwitchPlatformRequestOptions = { const SignUpRequestOptions = { config: { security: securityAccess.public(), - rateLimit: rateLimitOptions, + rateLimit: authnRateLimit, }, schema: { body: SignUpRequest, }, } +const CompleteSignUpRequestOptions = { + config: { + security: securityAccess.unscoped([PrincipalType.ONBOARDING]), + rateLimit: authnRateLimit, + }, + schema: { + body: CompleteSignUpRequest, + }, +} + +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(), - rateLimit: rateLimitOptions, + rateLimit: authnRateLimit, }, schema: { body: SignInRequest, diff --git a/packages/server/api/src/app/authentication/authentication.service.ts b/packages/server/api/src/app/authentication/authentication.service.ts index 379fe2f4898f..7cfd2d44cb17 100644 --- a/packages/server/api/src/app/authentication/authentication.service.ts +++ b/packages/server/api/src/app/authentication/authentication.service.ts @@ -2,7 +2,6 @@ import { ActivepiecesError, assertNotNullOrUndefined, ErrorCode, isNil } from '@ import { cryptoUtils } from '@activepieces/server-utils' import { ApEdition, ApEnvironment, ApFlagId, AuthenticationResponse, OtpType, PlatformWithoutSensitiveData, User, UserIdentity, UserIdentityProvider } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' -import { otpService } from '../ee/authentication/otp/otp-service' import { flagService } from '../flags/flag.service' import { system } from '../helper/system/system' import { AppSystemProp } from '../helper/system/system-props' @@ -10,10 +9,15 @@ import { platformService } from '../platform/platform.service' import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { authenticationUtils } from './authentication-utils' +import { disposableEmail } from './lib/disposable-email' +import { otpService } from './otp/otp-service' import { userIdentityService } from './user-identity/user-identity-service' export const authenticationService = (log: FastifyBaseLogger) => ({ async signUp(params: SignUpParams): Promise { + if (params.provider === UserIdentityProvider.EMAIL) { + await disposableEmail.assertMaySignUp({ email: params.email, log }) + } const platformId = params.platformId if (!isNil(platformId)) { @@ -108,6 +112,9 @@ export const authenticationService = (log: FastifyBaseLogger) => ({ projectId: null, }) }, + async resolvePreferredPlatformId({ identityId }: ResolvePreferredPlatformIdParams): Promise { + return getPreferredPlatformId(identityId, log) + }, async federatedAuthn(params: FederatedAuthnParams): Promise { const platformId = isNil(params.predefinedPlatformId) ? await getPreferredPlatformIdForFederatedAuthn(params.email, log) : params.predefinedPlatformId const userIdentity = await userIdentityService(log).getIdentityByEmail(params.email) @@ -249,6 +256,10 @@ async function getPreferredPlatformId(identityId: string, log: FastifyBaseLogger +type ResolvePreferredPlatformIdParams = { + identityId: string +} + type FederatedAuthnParams = { email: string firstName: string diff --git a/packages/server/api/src/app/authentication/lib/disposable-email.ts b/packages/server/api/src/app/authentication/lib/disposable-email.ts new file mode 100644 index 000000000000..213c4040575f --- /dev/null +++ b/packages/server/api/src/app/authentication/lib/disposable-email.ts @@ -0,0 +1,55 @@ +import { ActivepiecesError, ErrorCode } from '@activepieces/core-utils' +import disposableDomains from 'disposable-email-domains' +import wildcardDomains from 'disposable-email-domains/wildcard.json' +import { FastifyBaseLogger } from 'fastify' +import { system } from '../../helper/system/system' +import { AppSystemProp } from '../../helper/system/system-props' +import { userInvitationsService } from '../../user-invitations/user-invitation.service' + +const exactDomains = new Set(disposableDomains) +const suffixDomains: string[] = wildcardDomains + +function domainOf(email: string): string { + const at = email.lastIndexOf('@') + return at < 0 ? '' : email.slice(at + 1).trim().toLowerCase().replace(/\.$/, '') +} + +function isDisposable(email: string): boolean { + const domain = domainOf(email) + if (domain.length === 0) { + return false + } + if (exactDomains.has(domain)) { + return true + } + return suffixDomains.some((suffix) => domain === suffix || domain.endsWith(`.${suffix}`)) +} + +async function assertMaySignUp({ email, log }: AssertMaySignUpParams): Promise { + if (system.getBoolean(AppSystemProp.ALLOW_DISPOSABLE_EMAILS)) { + return + } + if (!isDisposable(email)) { + return + } + const invited = await userInvitationsService(log).hasAnyAcceptedInvitationsForEmail({ email }) + if (invited) { + return + } + throw new ActivepiecesError({ + code: ErrorCode.DOMAIN_NOT_ALLOWED, + params: { + domain: domainOf(email), + }, + }) +} + +export const disposableEmail = { + isDisposable, + assertMaySignUp, +} + +type AssertMaySignUpParams = { + email: string + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/authentication/lib/signup-names.ts b/packages/server/api/src/app/authentication/lib/signup-names.ts new file mode 100644 index 000000000000..f34d16db82d7 --- /dev/null +++ b/packages/server/api/src/app/authentication/lib/signup-names.ts @@ -0,0 +1,75 @@ +import { isNil } from '@activepieces/core-utils' + +const MAX_NAME_PART_LENGTH = 50 +const FALLBACK_FIRST_NAME = 'there' +const PLATFORM_NAME_NOUN = 'Platform' +const FALLBACK_PLATFORM_NAME = 'My Platform' +const SAFE_STRING_CHARS = /[./]/g + +function localPartTokens(email: string): string[] { + const at = email.indexOf('@') + const localPart = at >= 0 ? email.slice(0, at) : email + return localPart + .split(/[._+-]+/) + .map((token) => token.replace(/[^a-zA-Z0-9]/g, '')) + .filter((token) => token.length > 0) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) +} + +function firstNameFromEmail(email: string): string { + const [first] = localPartTokens(email) + return first ?? FALLBACK_FIRST_NAME +} + +function platformNameFromPerson({ firstName, email }: PlatformNameFromPersonParams): string { + const [given] = firstName.replace(SAFE_STRING_CHARS, '').trim().split(/\s+/) + if (isNil(given) || given.length === 0) { + const [fromEmail] = localPartTokens(email) + return isNil(fromEmail) ? FALLBACK_PLATFORM_NAME : platformNameFor(fromEmail) + } + return platformNameFor(given) +} + +function platformNameFor(name: string): string { + return `${possessive(name.slice(0, MAX_NAME_PART_LENGTH))} ${PLATFORM_NAME_NOUN}` +} + +function possessive(name: string): string { + return /['’]s$/.test(name) ? name : `${name}'s` +} + +function splitFullName({ fullName, email }: SplitFullNameParams): SplitName { + const tokens = fullName + .split(/\s+/) + .map((token) => token.replace(SAFE_STRING_CHARS, '')) + .filter((token) => token.length > 0) + const [first, ...rest] = tokens + if (isNil(first)) { + return { firstName: firstNameFromEmail(email), lastName: '' } + } + return { + firstName: first.slice(0, MAX_NAME_PART_LENGTH), + lastName: rest.join(' ').slice(0, MAX_NAME_PART_LENGTH), + } +} + +export const signupNames = { + firstNameFromEmail, + platformNameFromPerson, + splitFullName, +} + +type PlatformNameFromPersonParams = { + firstName: string + email: string +} + +type SplitFullNameParams = { + fullName: string + email: string +} + +type SplitName = { + firstName: string + lastName: string +} diff --git a/packages/server/api/src/app/authentication/lib/turnstile.ts b/packages/server/api/src/app/authentication/lib/turnstile.ts new file mode 100644 index 000000000000..e6035f4924b5 --- /dev/null +++ b/packages/server/api/src/app/authentication/lib/turnstile.ts @@ -0,0 +1,91 @@ +import { ActivepiecesError, ErrorCode, isNil, tryCatch } from '@activepieces/core-utils' +import { safeHttp } from '@activepieces/server-utils' +import { isAxiosError } from 'axios' +import { FastifyBaseLogger } from 'fastify' +import { system } from '../../helper/system/system' +import { AppSystemProp } from '../../helper/system/system-props' + +const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' +const VERIFY_TIMEOUT_MS = 5_000 + +function configuredValue(prop: AppSystemProp): string | undefined { + const raw = system.get(prop)?.trim() + return isNil(raw) || raw.length === 0 ? undefined : raw +} + +function siteKey(): string | undefined { + return isConfigured() ? configuredValue(AppSystemProp.TURNSTILE_SITE_KEY) : undefined +} + +function isConfigured(): boolean { + return !isNil(configuredValue(AppSystemProp.TURNSTILE_SITE_KEY)) + && !isNil(configuredValue(AppSystemProp.TURNSTILE_SECRET_KEY)) +} + +function siteVerifyAnswered(error: unknown): boolean { + return isAxiosError(error) && !isNil(error.response) +} + +function rejected(): ActivepiecesError { + return new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { + message: 'captchaVerificationFailed', + }, + }) +} + +async function assertSolved({ token, remoteIp, log }: AssertSolvedParams): Promise { + if (!isConfigured()) { + return + } + if (isNil(token) || token.length === 0) { + throw rejected() + } + const body = new URLSearchParams({ + secret: configuredValue(AppSystemProp.TURNSTILE_SECRET_KEY) ?? '', + response: token, + ...(isNil(remoteIp) ? {} : { remoteip: remoteIp }), + }) + const { data: response, error } = await tryCatch(() => safeHttp.axios.post( + VERIFY_URL, + body.toString(), + { + timeout: VERIFY_TIMEOUT_MS, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + )) + if (!isNil(error)) { + if (siteVerifyAnswered(error)) { + log.warn({ error }, '[turnstile#assertSolved] siteverify answered with an error status, refusing') + throw rejected() + } + log.warn({ error }, '[turnstile#assertSolved] challenge could not be verified, allowing the request through') + return + } + if (isNil(response)) { + log.warn('[turnstile#assertSolved] challenge could not be verified, allowing the request through') + return + } + if (!response.data.success) { + log.warn({ errors: response.data['error-codes'] }, '[turnstile#assertSolved] challenge rejected') + throw rejected() + } +} + +export const turnstile = { + isConfigured, + siteKey, + assertSolved, +} + +type SiteVerifyResponse = { + success: boolean + 'error-codes'?: string[] +} + +type AssertSolvedParams = { + token: string | undefined + remoteIp: string | undefined + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/authentication/otp/lib/otp-generator.ts b/packages/server/api/src/app/authentication/otp/lib/otp-generator.ts new file mode 100644 index 000000000000..1ef47c58e507 --- /dev/null +++ b/packages/server/api/src/app/authentication/otp/lib/otp-generator.ts @@ -0,0 +1,18 @@ +import { randomInt, randomUUID } from 'node:crypto' +import { OtpType } from '@activepieces/shared' + +export const otpGenerator = { + generate({ type }: GenerateParams): string { + if (type !== OtpType.EMAIL_LOGIN) { + return randomUUID() + } + const upperBound = 10 ** LOGIN_CODE_LENGTH + return randomInt(0, upperBound).toString().padStart(LOGIN_CODE_LENGTH, '0') + }, +} + +const LOGIN_CODE_LENGTH = 6 + +type GenerateParams = { + type: OtpType +} diff --git a/packages/server/api/src/app/ee/authentication/otp/otp-controller.ts b/packages/server/api/src/app/authentication/otp/otp-controller.ts similarity index 56% rename from packages/server/api/src/app/ee/authentication/otp/otp-controller.ts rename to packages/server/api/src/app/authentication/otp/otp-controller.ts index 5794d11321da..7230cf5b0d69 100644 --- a/packages/server/api/src/app/ee/authentication/otp/otp-controller.ts +++ b/packages/server/api/src/app/authentication/otp/otp-controller.ts @@ -1,11 +1,9 @@ import { CreateOtpRequestBody } from '@activepieces/shared' -import { RateLimitOptions } from '@fastify/rate-limit' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' -import { securityAccess } from '../../../core/security/authorization/fastify-security' -import { system } from '../../../helper/system/system' -import { AppSystemProp } from '../../../helper/system/system-props' -import { platformUtils } from '../../../platform/platform.utils' +import { securityAccess } from '../../core/security/authorization/fastify-security' +import { authnRateLimit } from '../../core/security/rate-limit' +import { platformUtils } from '../../platform/platform.utils' import { otpService } from './otp-service' export const otpController: FastifyPluginAsyncZod = async (app) => { @@ -20,18 +18,10 @@ export const otpController: FastifyPluginAsyncZod = async (app) => { }) } -const rateLimitOptions: RateLimitOptions = { - max: Number.parseInt( - system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_MAX), - 10, - ), - timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), -} - const CreateOtpRequest = { config: { security: securityAccess.public(), - rateLimit: rateLimitOptions, + rateLimit: authnRateLimit, }, schema: { body: CreateOtpRequestBody, diff --git a/packages/server/api/src/app/ee/authentication/otp/otp-entity.ts b/packages/server/api/src/app/authentication/otp/otp-entity.ts similarity index 89% rename from packages/server/api/src/app/ee/authentication/otp/otp-entity.ts rename to packages/server/api/src/app/authentication/otp/otp-entity.ts index 99d307c9b5c5..35124eba68ce 100644 --- a/packages/server/api/src/app/ee/authentication/otp/otp-entity.ts +++ b/packages/server/api/src/app/authentication/otp/otp-entity.ts @@ -3,7 +3,7 @@ import { EntitySchema } from 'typeorm' import { ApIdSchema, BaseColumnSchemaPart, -} from '../../../database/database-common' +} from '../../database/database-common' export type OtpSchema = OtpModel & { userIdentity: UserIdentity @@ -31,6 +31,11 @@ export const OtpEntity = new EntitySchema({ enum: OtpState, nullable: false, }, + attempts: { + type: Number, + nullable: false, + default: 0, + }, }, indices: [ { diff --git a/packages/server/api/src/app/ee/authentication/otp/otp-module.ts b/packages/server/api/src/app/authentication/otp/otp-module.ts similarity index 100% rename from packages/server/api/src/app/ee/authentication/otp/otp-module.ts rename to packages/server/api/src/app/authentication/otp/otp-module.ts diff --git a/packages/server/api/src/app/authentication/otp/otp-service.ts b/packages/server/api/src/app/authentication/otp/otp-service.ts new file mode 100644 index 000000000000..69ab2afb93bc --- /dev/null +++ b/packages/server/api/src/app/authentication/otp/otp-service.ts @@ -0,0 +1,123 @@ +import { apId, isNil, PlatformId } from '@activepieces/core-utils' +import { OtpModel, OtpState, OtpType } from '@activepieces/shared' +import dayjs from 'dayjs' +import { FastifyBaseLogger } from 'fastify' +import { repoFactory } from '../../core/db/repo-factory' +import { distributedLock } from '../../database/redis-connections' +import { emailService } from '../../ee/helper/email/email-service' +import { userIdentityService } from '../user-identity/user-identity-service' +import { otpGenerator } from './lib/otp-generator' +import { OtpEntity } from './otp-entity' + +const OTP_EXPIRATION_MS: Record = { + [OtpType.EMAIL_VERIFICATION]: 24 * 60 * 60 * 1000, + [OtpType.PASSWORD_RESET]: 10 * 60 * 1000, + [OtpType.EMAIL_LOGIN]: 10 * 60 * 1000, +} +const MAX_ATTEMPTS = 5 + +const repo = repoFactory(OtpEntity) + +export const otpService = (log: FastifyBaseLogger) => ({ + async createAndSend({ + platformId, + email, + type, + }: CreateParams): Promise { + const userIdentity = await userIdentityService(log).getIdentityByEmail(email) + if (!userIdentity) { + return + } + const existingOtp = await repo().findOneBy({ + identityId: userIdentity.id, + type, + }) + const existingOtpIsReusable = !isNil(existingOtp) && existingOtp.state === OtpState.PENDING && !otpIsExpired(existingOtp) + if (existingOtpIsReusable) { + await emailService(log).sendOtp({ + platformId, + userIdentity, + otp: existingOtp.value, + type: existingOtp.type, + }) + return + } + const newOtp: Omit = { + id: apId(), + updated: dayjs().toISOString(), + type, + identityId: userIdentity.id, + value: otpGenerator.generate({ type }), + state: OtpState.PENDING, + attempts: 0, + } + await repo().upsert(newOtp, ['identityId', 'type']) + await emailService(log).sendOtp({ + platformId, + userIdentity, + otp: newOtp.value, + type: newOtp.type, + }) + }, + + async confirm({ identityId, type, value }: ConfirmParams): Promise { + return distributedLock(log).runExclusive({ + key: `otp-confirm-${identityId}-${type}`, + timeoutInSeconds: 15, + fn: async () => { + const otp = await repo().findOneBy({ identityId, type }) + if (isNil(otp)) { + return false + } + if (otp.attempts >= MAX_ATTEMPTS) { + await discard({ otp, identityId, type, log }) + return false + } + const otpIsPending = otp.state === OtpState.PENDING + const otpIsNotExpired = !otpIsExpired(otp) + const otpMatches = otp.value === value + if (otpIsNotExpired && otpMatches && otpIsPending) { + await repo().delete({ id: otp.id }) + return true + } + await countAttempt(otp.id) + if (otp.attempts + 1 >= MAX_ATTEMPTS) { + await discard({ otp, identityId, type, log }) + } + return false + }, + }) + }, +}) + +async function countAttempt(otpId: string): Promise { + await repo().query('UPDATE "otp" SET "attempts" = "attempts" + 1 WHERE "id" = $1', [otpId]) +} + +async function discard({ otp, identityId, type, log }: DiscardParams): Promise { + await repo().delete({ id: otp.id }) + log.warn({ identityId, type }, '[otpService#confirm] attempt budget exhausted, credential discarded') +} + +function otpIsExpired(otp: OtpModel): boolean { + return dayjs().diff(otp.updated, 'milliseconds') >= OTP_EXPIRATION_MS[otp.type] +} + +type CreateParams = { + platformId: PlatformId | null + email: string + type: OtpType +} + +type DiscardParams = { + otp: OtpModel + identityId: string + type: OtpType + log: FastifyBaseLogger +} + +type ConfirmParams = { + identityId: string + type: OtpType + value: string +} diff --git a/packages/server/api/src/app/authentication/passwordless-auth.service.ts b/packages/server/api/src/app/authentication/passwordless-auth.service.ts new file mode 100644 index 000000000000..4f47579d29c3 --- /dev/null +++ b/packages/server/api/src/app/authentication/passwordless-auth.service.ts @@ -0,0 +1,192 @@ +import { ActivepiecesError, ErrorCode, isNil } from '@activepieces/core-utils' +import { cryptoUtils } from '@activepieces/server-utils' +import { ApFlagId, AuthenticationResponse, OtpType, TelemetryEventName, UserIdentity, UserIdentityProvider } from '@activepieces/shared' +import { FastifyBaseLogger } from 'fastify' +import { flagService } from '../flags/flag.service' +import { rejectedPromiseHandler } from '../helper/promise-handler' +import { system } from '../helper/system/system' +import { AppSystemProp } from '../helper/system/system-props' +import { telemetry } from '../helper/telemetry.utils' +import { platformService } from '../platform/platform.service' +import { userService } from '../user/user-service' +import { userInvitationsService } from '../user-invitations/user-invitation.service' +import { authenticationUtils } from './authentication-utils' +import { authenticationService } from './authentication.service' +import { disposableEmail } from './lib/disposable-email' +import { signupNames } from './lib/signup-names' +import { turnstile } from './lib/turnstile' +import { otpService } from './otp/otp-service' +import { userIdentityService } from './user-identity/user-identity-service' + +export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ + async requestCode({ email, platformId, captchaToken, remoteIp }: RequestCodeParams): Promise { + await turnstile.assertSolved({ token: captchaToken, remoteIp, log }) + const existingIdentity = await userIdentityService(log).getIdentityByEmail(email) + if (isNil(existingIdentity)) { + await disposableEmail.assertMaySignUp({ email, log }) + } + if (!isNil(platformId)) { + await assertPlatformAuthIsOpenTo({ email, platformId, log }) + const mayJoin = await mayJoinPlatform({ email, platformId, identity: existingIdentity, log }) + if (!mayJoin) { + return + } + } + if (isNil(existingIdentity)) { + await userIdentityService(log).create({ + email, + password: await cryptoUtils.generateRandomPassword(), + firstName: signupNames.firstNameFromEmail(email), + lastName: '', + trackEvents: true, + newsLetter: false, + provider: UserIdentityProvider.EMAIL, + verified: false, + }) + } + await otpService(log).createAndSend({ + platformId, + email, + type: OtpType.EMAIL_LOGIN, + }) + const identity = await userIdentityService(log).getIdentityByEmail(email) + if (!isNil(identity)) { + rejectedPromiseHandler(telemetry(log).trackIdentity(identity.id, { + name: TelemetryEventName.EMAIL_CODE_REQUESTED, + payload: { isNewIdentity: isNil(existingIdentity) }, + }), log) + } + }, + + async verifyCode({ email, code, platformId }: VerifyCodeParams): Promise { + const identity = await userIdentityService(log).getIdentityByEmail(email) + if (isNil(identity)) { + throw new ActivepiecesError({ code: ErrorCode.INVALID_OTP, params: {} }) + } + if (!isNil(platformId)) { + await assertPlatformAuthIsOpenTo({ email, platformId, log }) + } + const codeIsValid = await otpService(log).confirm({ + identityId: identity.id, + type: OtpType.EMAIL_LOGIN, + value: code, + }) + if (!codeIsValid) { + throw new ActivepiecesError({ code: ErrorCode.INVALID_OTP, params: {} }) + } + const verifiedIdentity = identity.verified ? identity : await userIdentityService(log).verifyAndDiscardPassword(identity.id) + await flagService(log).save({ id: ApFlagId.USER_CREATED, value: true }) + + const preferredPlatformId = isNil(platformId) + ? await authenticationService(log).resolvePreferredPlatformId({ identityId: verifiedIdentity.id }) + : platformId + rejectedPromiseHandler(telemetry(log).trackIdentity(verifiedIdentity.id, { + name: TelemetryEventName.EMAIL_CODE_VERIFIED, + payload: { needsNameStep: isNil(preferredPlatformId) }, + }), log) + + if (!isNil(platformId)) { + const mayJoin = await mayJoinPlatform({ email, platformId, identity: verifiedIdentity, log }) + if (!mayJoin) { + throw new ActivepiecesError({ + code: ErrorCode.INVITATION_ONLY_SIGN_UP, + params: { message: 'User is not invited to the platform' }, + }) + } + const user = await userService(log).getOrCreateWithProject({ + identity: verifiedIdentity, + platformId, + }) + await userInvitationsService(log).provisionUserInvitation({ email }) + return authenticationUtils(log).getProjectAndToken({ + userId: user.id, + platformId, + projectId: null, + }) + } + + if (!isNil(preferredPlatformId)) { + await assertPlatformAuthIsOpenTo({ email, platformId: preferredPlatformId, log }) + const user = await userService(log).getOrCreateWithProject({ + identity: verifiedIdentity, + platformId: preferredPlatformId, + }) + return authenticationUtils(log).getProjectAndToken({ + userId: user.id, + platformId: preferredPlatformId, + projectId: null, + }) + } + return authenticationUtils(log).getOnboardingResponse({ identityId: verifiedIdentity.id }) + }, + + async completeSignUp({ identityId, fullName }: CompleteSignUpParams): Promise { + const identity = await userIdentityService(log).getOneOrFail({ id: identityId }) + const { firstName, lastName } = signupNames.splitFullName({ fullName, email: identity.email }) + const writeNames = async (): Promise => { + await userIdentityService(log).updateNames({ id: identityId, firstName, lastName }) + } + const { response, provisioned } = await platformService(log).createPlatformWithProject({ + identityId, + name: signupNames.platformNameFromPerson({ firstName, email: identity.email }), + invalidatePreviousTokens: false, + isFirstPlatform: true, + callerTokenVersion: undefined, + beforeProvision: writeNames, + }) + return { response, signedUp: provisioned } + }, +}) + +async function assertPlatformAuthIsOpenTo({ email, platformId, log }: PlatformGateParams): Promise { + await authenticationUtils(log).assertEmailAuthIsEnabled({ + platformId, + provider: UserIdentityProvider.EMAIL, + }) + await authenticationUtils(log).assertDomainIsAllowed({ email, platformId }) +} + +async function mayJoinPlatform({ email, platformId, identity, log }: MayJoinPlatformParams): Promise { + if (system.get(AppSystemProp.ALLOW_OPEN_SIGN_UP) === 'true') { + return true + } + const isExistingMember = !isNil(identity) + && !isNil(await userService(log).getOneByIdentityAndPlatform({ identityId: identity.id, platformId })) + if (isExistingMember) { + return true + } + return userInvitationsService(log).hasAnyAcceptedInvitations({ platformId, email }) +} + +type RequestCodeParams = { + email: string + platformId: string | null + captchaToken: string | undefined + remoteIp: string | undefined +} + +type CompleteSignUpResult = { + response: AuthenticationResponse + signedUp: boolean +} + +type CompleteSignUpParams = { + identityId: string + fullName: string +} + +type VerifyCodeParams = { + email: string + code: string + platformId: string | null +} + +type PlatformGateParams = { + email: string + platformId: string + log: FastifyBaseLogger +} + +type MayJoinPlatformParams = PlatformGateParams & { + identity: UserIdentity | null +} diff --git a/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts b/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts index 94a3028e259e..cc612d574344 100644 --- a/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts +++ b/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts @@ -1,4 +1,5 @@ import { ActivepiecesError, apId, ErrorCode, isNil, spreadIfDefined } from '@activepieces/core-utils' +import { cryptoUtils } from '@activepieces/server-utils' import { UserIdentity } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { nanoid } from 'nanoid' @@ -96,6 +97,11 @@ export const userIdentityService = (log: FastifyBaseLogger) => ({ tokenVersion: nanoid(), }) }, + async updateNames({ id, firstName, lastName }: UpdateNamesParams): Promise { + await userIdentityRepository().update(id, { firstName, lastName }) + return this.getOneOrFail({ id }) + }, + async verify(id: string): Promise { const user = await userIdentityRepository().findOneByOrFail({ id }) if (user.verified) { @@ -111,6 +117,27 @@ export const userIdentityService = (log: FastifyBaseLogger) => ({ verified: true, }) }, + // A password sitting on an unverified identity was chosen by whoever typed it, + // which is not necessarily the person who reads the inbox. Verifying by emailed + // code proves the inbox, so that password must not outlive the check: keeping it + // would hand the account to anyone who registered the address first. + async verifyAndDiscardPassword(id: string): Promise { + const user = await userIdentityRepository().findOneByOrFail({ id }) + if (user.verified) { + throw new ActivepiecesError({ + code: ErrorCode.AUTHORIZATION, + params: { + message: 'User is already verified', + }, + }) + } + return userIdentityRepository().save({ + ...user, + verified: true, + password: await passwordHasher.hash(await cryptoUtils.generateRandomPassword()), + tokenVersion: nanoid(), + }) + }, async update(id: string, params: UpdateParams): Promise { await userIdentityRepository().update(id, { ...params, @@ -132,6 +159,12 @@ type GetOneOrFailParams = { id: string } +type UpdateNamesParams = { + id: string + firstName: string + lastName: string +} + type UpdatePasswordParams = { id: string newPassword: string diff --git a/packages/server/api/src/app/core/security/rate-limit.ts b/packages/server/api/src/app/core/security/rate-limit.ts index 87629037b1de..af6cf1ac6167 100644 --- a/packages/server/api/src/app/core/security/rate-limit.ts +++ b/packages/server/api/src/app/core/security/rate-limit.ts @@ -1,4 +1,4 @@ -import RateLimitPlugin from '@fastify/rate-limit' +import RateLimitPlugin, { RateLimitOptions } from '@fastify/rate-limit' import FastifyPlugin from 'fastify-plugin' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { redisConnections } from '../../database/redis-connections' @@ -21,3 +21,19 @@ export const rateLimitModule: FastifyPluginAsyncZod = FastifyPlugin( } }, ) + +export const authnRateLimit: RateLimitOptions = { + max: Number.parseInt( + system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_MAX), + 10, + ), + timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), +} + +export const emailCodeRateLimit: RateLimitOptions = { + max: Number.parseInt( + system.getOrThrow(AppSystemProp.API_RATE_LIMIT_EMAIL_CODE_MAX), + 10, + ), + timeWindow: system.getOrThrow(AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW), +} diff --git a/packages/server/api/src/app/database/database-connection.ts b/packages/server/api/src/app/database/database-connection.ts index 0ea4cddef070..65b8a21cce1d 100644 --- a/packages/server/api/src/app/database/database-connection.ts +++ b/packages/server/api/src/app/database/database-connection.ts @@ -7,6 +7,7 @@ import { AIProviderEntity } from '../ai/ai-provider-entity' import { AiToolConfigEntity } from '../ai/ai-tool-config-entity' import { PlatformAnalyticsReportEntity } from '../analytics/platform-analytics-report.entity' import { AppConnectionEntity } from '../app-connection/app-connection.entity' +import { OtpEntity } from '../authentication/otp/otp-entity' import { UserIdentityEntity } from '../authentication/user-identity/user-identity-entity' import { AgentConversationEntity } from '../ee/agent/agent-conversation-entity' import { AgentEntity } from '../ee/agent/agent-entity' @@ -17,7 +18,6 @@ import { ApiKeyEntity } from '../ee/api-keys/api-key-entity' import { AppCredentialEntity } from '../ee/app-credentials/app-credentials.entity' import { AppSumoEntity } from '../ee/appsumo/appsumo.entity' import { AuditEventEntity } from '../ee/audit-logs/audit-event-entity' -import { OtpEntity } from '../ee/authentication/otp/otp-entity' import { ConnectionKeyEntity } from '../ee/connection-keys/connection-key.entity' import { EmbedSubdomainEntity } from '../ee/embed-subdomain/embed-subdomain.entity' import { OAuthAppEntity } from '../ee/oauth-apps/oauth-app.entity' diff --git a/packages/server/api/src/app/database/migration/postgres/1824000000000-AddAttemptsToOtp.ts b/packages/server/api/src/app/database/migration/postgres/1824000000000-AddAttemptsToOtp.ts new file mode 100644 index 000000000000..712d191f85c3 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1824000000000-AddAttemptsToOtp.ts @@ -0,0 +1,21 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class AddAttemptsToOtp1824000000000 implements Migration { + name = 'AddAttemptsToOtp1824000000000' + breaking = false + release = '0.88.0' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "otp" + ADD "attempts" integer NOT NULL DEFAULT '0' + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "otp" DROP COLUMN "attempts" + `) + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 080651fc6d07..8f7cdb95e4fd 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -414,6 +414,7 @@ import { AddAuditEventPlatformIdCreatedIdIndex1820000000000 } from './migration/ import { AddAgentConversationFlowStepRetentionIndex1821000000000 } from './migration/postgres/1821000000000-AddAgentConversationFlowStepRetentionIndex' import { RenameChatTablesToAgent1822000000000 } from './migration/postgres/1822000000000-RenameChatTablesToAgent' import { AddRenamedChatTableCompatViews1823000000000 } from './migration/postgres/1823000000000-AddRenamedChatTableCompatViews' +import { AddAttemptsToOtp1824000000000 } from './migration/postgres/1824000000000-AddAttemptsToOtp' import { AddAgentTable1825000000000 } from './migration/postgres/1825000000000-AddAgentTable' const getSslConfig = (): boolean | TlsOptions => { @@ -844,6 +845,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddAgentConversationFlowStepRetentionIndex1821000000000, RenameChatTablesToAgent1822000000000, AddRenamedChatTableCompatViews1823000000000, + AddAttemptsToOtp1824000000000, AddAgentTable1825000000000, ] return migrations diff --git a/packages/server/api/src/app/database/seeds/dev-seeds.ts b/packages/server/api/src/app/database/seeds/dev-seeds.ts index f207693a18bc..f1616ee5e69c 100644 --- a/packages/server/api/src/app/database/seeds/dev-seeds.ts +++ b/packages/server/api/src/app/database/seeds/dev-seeds.ts @@ -50,6 +50,8 @@ const seedDevUser = async (): Promise => { identityId: response.id, name: 'dev\'s Platform', invalidatePreviousTokens: true, + isFirstPlatform: true, + callerTokenVersion: undefined, }) log.info({ email: DEV_EMAIL, password: DEV_PASSWORD }, '[devSeeds#seedDevUser] Dev user and platform created') 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 eb60fd529dd1..eae45dad67be 100644 --- a/packages/server/api/src/app/ee/agent/agent-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-controller.ts @@ -1,5 +1,5 @@ -import { ApId, assertNotNullOrUndefined, Permission, SeekPage, UserId } from '@activepieces/core-utils' -import { Agent, ApplicationEventName, CreateAgentRequest, ListAgentsRequest, PrincipalType, SERVICE_KEY_SECURITY_OPENAPI, UpdateAgentRequest } from '@activepieces/shared' +import { ActivepiecesError, ApId, assertNotNullOrUndefined, ErrorCode, Permission, SeekPage, UserId } from '@activepieces/core-utils' +import { Agent, AgentSummary, AgentTemplate, ApplicationEventName, CreateAgentRequest, DraftAgentRequest, DraftAgentResponse, ListAgentsRequest, PrincipalType, SERVICE_KEY_SECURITY_OPENAPI, UpdateAgentRequest } from '@activepieces/shared' import { FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' @@ -7,9 +7,16 @@ import { z } from 'zod' import { ProjectResourceType } from '../../core/security/authorization/common' import { securityAccess } from '../../core/security/authorization/fastify-security' import { applicationEvents } from '../../helper/application-events' +import { paginationHelper } from '../../helper/pagination/pagination-utils' import { securityHelper } from '../../helper/security-helper' +import { assertCreditsAndAppSumoNotExceeded } from '../../platform/billing-provider' +import { agentDraftAi } from './agent-draft-ai' import { AgentEntity } from './agent-entity' -import { agentService } from './agent-service' +import { agentHelpers } from './agent-helpers' +import { agentAudit, agentRedaction, agentService } from './agent-service' +import { AGENT_TEMPLATES } from './agent-templates' + +export const DRAFTS_PER_MINUTE = 20 export const agentController: FastifyPluginAsyncZod = async (app) => { app.post('/', CreateAgentRoute, async (request, reply) => { @@ -23,10 +30,10 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { action: ApplicationEventName.AGENT_CREATED, data: { agent: { id: agent.id, displayName: agent.displayName } }, }) - return reply.status(StatusCodes.CREATED).send(agent) + return reply.status(StatusCodes.CREATED).send(agentRedaction.withoutToolSecrets(agent)) }) - app.get('/', ListAgentsRoute, async (request): Promise> => { + app.get('/', ListAgentsRoute, async (request): Promise> => { return agentService(request.log).list({ platformId: request.principal.platform.id, userId: await resolveUserId(request), @@ -36,12 +43,33 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { }) }) + app.get('/templates', ListTemplatesRoute, async (): Promise> => { + return paginationHelper.createPage([...AGENT_TEMPLATES], null) + }) + + app.post('/draft', DraftAgentRoute, async (request): Promise => { + const platformId = request.principal.platform.id + await assertCreditsAndAppSumoNotExceeded({ platformId, log: request.log }) + const { allowed, count } = await agentHelpers.incrementAndCheckLimit({ + key: `agent-draft:${platformId}:${request.principal.id}`, + limit: DRAFTS_PER_MINUTE, + ttlSeconds: 60, + }) + if (!allowed) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: `You drafted ${count} agents in the last minute, above the limit of ${DRAFTS_PER_MINUTE}` }, + }) + } + return agentDraftAi(request.log).draft({ platformId, projectId: request.projectId, prompt: request.body.prompt }) + }) + app.get('/:id', GetAgentRoute, async (request): Promise => { - return agentService(request.log).getOneOrThrow({ + return agentRedaction.withoutToolSecrets(await agentService(request.log).getOneOrThrow({ id: request.params.id, projectId: request.projectId, userId: await resolveUserId(request), - }) + })) }) app.post('/:id', UpdateAgentRoute, async (request): Promise => { @@ -55,7 +83,33 @@ export const agentController: FastifyPluginAsyncZod = async (app) => { action: ApplicationEventName.AGENT_UPDATED, data: { agent: { id: agent.id, displayName: agent.displayName } }, }) - return agent + return agentRedaction.withoutToolSecrets(agent) + }) + + app.post('/:id/publish', PublishAgentRoute, async (request): Promise => { + const agent = await agentService(request.log).publish({ + id: request.params.id, + projectId: request.projectId, + userId: await resolveUserId(request), + }) + applicationEvents(request.log).sendUserEvent(request, { + action: ApplicationEventName.AGENT_PUBLISHED, + data: { agent: { id: agent.id, displayName: agent.displayName, ...agentAudit.describePublished({ published: agent.draft }) } }, + }) + return agentRedaction.withoutToolSecrets(agent) + }) + + app.post('/:id/unpublish', UnpublishAgentRoute, async (request): Promise => { + const agent = await agentService(request.log).unpublish({ + id: request.params.id, + projectId: request.projectId, + userId: await resolveUserId(request), + }) + applicationEvents(request.log).sendUserEvent(request, { + action: ApplicationEventName.AGENT_UNPUBLISHED, + data: { agent: { id: agent.id, displayName: agent.displayName } }, + }) + return agentRedaction.withoutToolSecrets(agent) }) app.delete('/:id', DeleteAgentRoute, async (request, reply): Promise => { @@ -107,7 +161,40 @@ const ListAgentsRoute = { description: 'List agents across every project the caller can read', querystring: ListAgentsRequest, response: { - [StatusCodes.OK]: SeekPage(Agent), + [StatusCodes.OK]: SeekPage(AgentSummary), + }, + }, +} + +const ListTemplatesRoute = { + config: { + security: securityAccess.publicPlatform([PrincipalType.USER, PrincipalType.SERVICE]), + }, + schema: { + tags: ['agents'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + description: 'List the starter agents, none of which need a connection', + response: { + [StatusCodes.OK]: SeekPage(AgentTemplate), + }, + }, +} + +const DraftAgentRoute = { + config: { + security: securityAccess.project( + [PrincipalType.USER, PrincipalType.SERVICE], + Permission.WRITE_AGENT, + { type: ProjectResourceType.BODY }, + ), + }, + schema: { + tags: ['agents'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + description: 'Draft an agent from a sentence, for review before it is created', + body: DraftAgentRequest, + response: { + [StatusCodes.OK]: DraftAgentResponse, }, }, } @@ -151,6 +238,44 @@ const UpdateAgentRoute = { }, } +const PublishAgentRoute = { + config: { + security: securityAccess.project( + [PrincipalType.USER, PrincipalType.SERVICE], + Permission.WRITE_AGENT, + { type: ProjectResourceType.TABLE, tableName: AgentEntity }, + ), + }, + schema: { + tags: ['agents'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + description: 'Publish an agent, so flow steps run the current draft', + params: z.object({ id: ApId }), + response: { + [StatusCodes.OK]: Agent, + }, + }, +} + +const UnpublishAgentRoute = { + config: { + security: securityAccess.project( + [PrincipalType.USER, PrincipalType.SERVICE], + Permission.WRITE_AGENT, + { type: ProjectResourceType.TABLE, tableName: AgentEntity }, + ), + }, + schema: { + tags: ['agents'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + description: 'Take an agent offline, so flow steps stop running it', + params: z.object({ id: ApId }), + response: { + [StatusCodes.OK]: Agent, + }, + }, +} + const DeleteAgentRoute = { config: { security: securityAccess.project( @@ -162,7 +287,7 @@ const DeleteAgentRoute = { schema: { tags: ['agents'], security: [SERVICE_KEY_SECURITY_OPENAPI], - description: 'Delete an agent, unless a published flow uses it', + description: 'Delete an agent', params: z.object({ id: ApId }), response: { [StatusCodes.NO_CONTENT]: z.never(), diff --git a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts new file mode 100644 index 000000000000..612798257121 --- /dev/null +++ b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, ProjectId, tryCatch } from '@activepieces/core-utils' +import { agentAiUtils } from '@activepieces/server-utils' +import { CHAT_BYOK_CREDIT_WEIGHT, DraftAgentResponse, isAppSumoCreditedPlan } from '@activepieces/shared' +import { generateText, Output, zodSchema } from 'ai' +import { FastifyBaseLogger } from 'fastify' +import { trackBillingAndSendTelemetry } from '../../platform/billing-and-telemetry' +import { CreditUsageSource } from '../../platform/billing-provider' +import { platformPlanService } from '../platform/platform-plan/platform-plan.service' +import { agentHelpers } from './agent-helpers' + +const DRAFT_TIMEOUT_MS = 30_000 +const FAST_TIER_ID = 'fast' +const DRAFT_SYSTEM_PROMPT = readFileSync(path.resolve('packages/server/api/src/assets/prompts/agent-draft-prompt.md'), 'utf8') + +export const agentDraftAi = (log: FastifyBaseLogger) => ({ + async draft({ platformId, projectId, prompt }: DraftParams): Promise { + const { data: model, error: modelError } = await tryCatch(() => agentHelpers.resolveFastModel({ platformId, log })) + if (!isNil(modelError) || isNil(model)) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'Connect an AI provider before drafting an agent, or start from a starter agent instead' }, + }) + } + + const { data: generated, error: generateError } = await tryCatch(() => generateText({ + model, + instructions: DRAFT_SYSTEM_PROMPT, + prompt, + output: Output.object({ schema: zodSchema(DraftAgentResponse) }), + telemetry: agentAiUtils.buildTelemetry({ functionId: 'agent-draft' }), + abortSignal: AbortSignal.timeout(DRAFT_TIMEOUT_MS), + })) + if (!isNil(generateError) || isNil(generated)) { + log.warn({ error: generateError, platform: { id: platformId } }, '[agentDraftAi] Could not draft an agent') + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'Could not draft an agent from that description, try rewording it' }, + }) + } + await debitDraft({ platformId, projectId, log }) + return generated.output + }, +}) + +async function debitDraft({ platformId, projectId, log }: { platformId: PlatformId, projectId: ProjectId, log: FastifyBaseLogger }): Promise { + const { error } = await tryCatch(async () => { + const provider = await agentHelpers.resolveChatProviderName({ platformId, log }) + const value = provider === AIProviderName.ACTIVEPIECES ? agentHelpers.resolveTier({ tierId: FAST_TIER_ID }).creditWeight : CHAT_BYOK_CREDIT_WEIGHT + const platformPlan = await platformPlanService(log).getOrCreateForPlatform(platformId) + const usage = { + platformId, + value, + source: CreditUsageSource.AGENT_DRAFT as const, + idempotencyKey: `agent-draft:${apId()}`, + properties: { platformId, projectId, provider }, + } + await trackBillingAndSendTelemetry({ + log, + licenseKey: platformPlan.licenseKey, + credits: usage, + ...(isAppSumoCreditedPlan(platformPlan.plan) ? { appSumo: { ...usage, idempotencyKey: `agent-draft-appsumo:${apId()}` } } : {}), + }) + }) + if (!isNil(error)) { + log.warn({ error, platform: { id: platformId } }, '[agentDraftAi] Draft usage was not recorded') + } +} + +type DraftParams = { + platformId: PlatformId + projectId: ProjectId + prompt: string +} 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 487b60cdc6a8..2983a155f41e 100644 --- a/packages/server/api/src/app/ee/agent/agent-service.ts +++ b/packages/server/api/src/app/ee/agent/agent-service.ts @@ -1,19 +1,25 @@ -import { ActivepiecesError, ApId, apId, Cursor, ErrorCode, isNil, Permission, PlatformId, ProjectId, SeekPage, UserId } from '@activepieces/core-utils' -import { Agent, AgentVisibility, CreateAgentRequest, UpdateAgentRequest } from '@activepieces/shared' +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, agentUtils, AgentVisibility, CreateAgentRequest, DefaultProjectRole, UpdateAgentRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { Brackets, In, SelectQueryBuilder } from 'typeorm' import { repoFactory } from '../../core/db/repo-factory' import { buildPaginator } from '../../helper/pagination/build-paginator' import { paginationHelper } from '../../helper/pagination/pagination-utils' +import { projectService } from '../../project/project-service' import { userService } from '../../user/user-service' import { projectMemberService } from '../projects/project-members/project-member.service' import { AgentEntity, AgentWithRelations } from './agent-entity' import { agentHelpers } from './agent-helpers' const DEFAULT_PAGE_SIZE = 20 - export const agentRepo = repoFactory(AgentEntity) +export const agentAudit = { describePublished } + +export const agentRedaction = { withoutToolSecrets } + export const agentService = (log: FastifyBaseLogger) => ({ async create({ projectId, ownerId, request }: CreateParams): Promise { const visibility = request.visibility ?? AgentVisibility.PROJECT @@ -27,8 +33,8 @@ export const agentService = (log: FastifyBaseLogger) => ({ icon: request.icon, color: request.color, visibility, - sharedWithUserIds: await resolveShare({ visibility, sharedWithUserIds: request.sharedWithUserIds, projectId, log }), - draft: request.draft, + sharedWithUserIds: await resolveShare({ visibility, requested: request.sharedWithUserIds, stored: [], projectId, log }), + draft: sanitizeObjectForPostgresql(request.draft), published: null, }) }, @@ -51,13 +57,14 @@ export const agentService = (log: FastifyBaseLogger) => ({ }) const { data, cursor: newCursor } = await paginator.paginate( - visibleAgents({ userId }).andWhere({ projectId: In(readableProjectIds) }), + visibleAgents({ userId, isProjectAdmin: false }).andWhere({ projectId: In(readableProjectIds) }), ) return paginationHelper.createPage(data, newCursor) }, async getOneOrThrow({ id, projectId, userId }: GetParams): Promise { - const agent = await visibleAgents({ userId }).andWhere({ id, projectId }).getOne() + const isProjectAdmin = await isProjectAdministrator({ projectId, userId, log }) + const agent = await visibleAgents({ userId, isProjectAdmin }).andWhere({ id, projectId }).getOne() if (isNil(agent)) { throw agentNotFound(id) } @@ -66,14 +73,58 @@ export const agentService = (log: FastifyBaseLogger) => ({ async update({ id, projectId, userId, request }: UpdateParams): Promise { const agent = await this.getOneOrThrow({ id, projectId, userId }) + await assertMayChangeWhoCanSee({ agent, request, projectId, userId, log }) const visibility = request.visibility ?? agent.visibility const sharedWithUserIds = await resolveShare({ visibility, - sharedWithUserIds: request.sharedWithUserIds ?? agent.sharedWithUserIds, + requested: request.sharedWithUserIds, + stored: agent.sharedWithUserIds, projectId, log, }) - return agentRepo().save({ ...agent, ...request, visibility, sharedWithUserIds }) + const draft = isNil(request.draft) ? agent.draft : sanitizeObjectForPostgresql(request.draft) + await agentRepo().save({ ...omit(agent, ['published']), ...request, draft, visibility, sharedWithUserIds }) + return this.getOneOrThrow({ id, projectId, userId }) + }, + + async publish({ id, projectId, userId }: GetParams): Promise { + const agent = await this.getOneOrThrow({ id, projectId, userId }) + if (!agentUtils.isPublishable(agent.draft)) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'An agent needs instructions before it can be published' }, + }) + } + const published = await agentRepo() + .createQueryBuilder() + .update() + .set({ published: () => '"draft"' }) + .where('"id" = :id AND "projectId" = :projectId', { id, projectId }) + .andWhere('"draft" = CAST(:reviewedDraft AS jsonb)', { reviewedDraft: JSON.stringify(agent.draft) }) + .andWhere(visibleToUser({ userId, prefix: '', isProjectAdmin: await isProjectAdministrator({ projectId, userId, log }) })) + .returning('id') + .execute() + + const publishedRows: unknown[] = published.raw ?? [] + if (publishedRows.length === 0) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'The agent changed while it was being published, review it and publish again' }, + }) + } + return this.getOneOrThrow({ id, projectId, userId }) + }, + + async unpublish({ id, projectId, userId }: GetParams): Promise { + await this.getOneOrThrow({ id, projectId, userId }) + await agentRepo() + .createQueryBuilder() + .update() + .set({ published: null }) + .where('"id" = :id AND "projectId" = :projectId', { id, projectId }) + .andWhere(visibleToUser({ userId, prefix: '', isProjectAdmin: await isProjectAdministrator({ projectId, userId, log }) })) + .execute() + return this.getOneOrThrow({ id, projectId, userId }) }, async delete({ id, projectId, userId }: GetParams): Promise { @@ -83,32 +134,72 @@ export const agentService = (log: FastifyBaseLogger) => ({ }, }) -function visibleAgents({ userId }: { userId: UserId }): SelectQueryBuilder { +function visibleAgents({ userId, isProjectAdmin }: { userId: UserId, isProjectAdmin: boolean }): SelectQueryBuilder { return agentRepo() .createQueryBuilder('agent') - .where(new Brackets((qb) => { - qb.where('agent.visibility = :projectVisibility', { projectVisibility: AgentVisibility.PROJECT }) - .orWhere('agent."ownerId" = :userId', { userId }) - .orWhere(':userId = ANY(agent."sharedWithUserIds")', { userId }) - })) + .where(visibleToUser({ userId, prefix: 'agent.', isProjectAdmin })) } -async function resolveShare({ visibility, sharedWithUserIds, projectId, log }: ResolveShareParams): Promise { - if (visibility === AgentVisibility.PROJECT || isNil(sharedWithUserIds) || sharedWithUserIds.length === 0) { +function visibleToUser({ userId, prefix, isProjectAdmin }: VisibilityParams): Brackets { + return new Brackets((qb) => { + qb.where(`${prefix}"visibility" = :projectVisibility`, { projectVisibility: AgentVisibility.PROJECT }) + .orWhere(`${prefix}"ownerId" = :userId`, { userId }) + .orWhere(`:userId = ANY(${prefix}"sharedWithUserIds")`, { userId }) + if (isProjectAdmin) { + qb.orWhere('1 = 1') + } + }) +} + +async function isProjectAdministrator({ projectId, userId, log }: { projectId: ProjectId, userId: UserId, log: FastifyBaseLogger }): Promise { + const role = await projectMemberService(log).getRole({ projectId, userId }) + return role?.name === DefaultProjectRole.ADMIN +} + +async function resolveShare({ visibility, requested, stored, projectId, log }: ResolveShareParams): Promise { + if (visibility === AgentVisibility.PROJECT) { return [] } - const uniqueUserIds = [...new Set(sharedWithUserIds)] - const members = await projectMemberService(log).listProjectMemberUserIds({ projectId }) - const strangers = uniqueUserIds.filter((userId) => !members.includes(userId)) + const uniqueUserIds = [...new Set(requested ?? stored)] + if (uniqueUserIds.length === 0) { + return [] + } + const withAccess = await listUsersWithProjectAccess({ projectId, log }) + if (isNil(requested)) { + return uniqueUserIds.filter((userId) => withAccess.includes(userId)) + } + const strangers = uniqueUserIds.filter((userId) => !withAccess.includes(userId)) if (strangers.length > 0) { throw new ActivepiecesError({ code: ErrorCode.VALIDATION, - params: { message: 'An agent can only be shared with people who are already in its project' }, + params: { message: 'An agent can only be shared with people who already have access to its project' }, }) } return uniqueUserIds } +async function assertMayChangeWhoCanSee({ agent, request, projectId, userId, log }: AssertShareParams): Promise { + const changesWhoCanSee = !isNil(request.visibility) || !isNil(request.sharedWithUserIds) + if (!changesWhoCanSee || agent.ownerId === userId) { + return + } + if (await isProjectAdministrator({ projectId, userId, log })) { + return + } + throw new ActivepiecesError({ + code: ErrorCode.AUTHORIZATION, + params: { message: 'Only the person who created an agent, or a project admin, can change who sees it' }, + }) +} + +async function listUsersWithProjectAccess({ projectId, log }: { projectId: ProjectId, log: FastifyBaseLogger }): Promise { + const [members, project] = await Promise.all([ + projectMemberService(log).listProjectMemberUserIds({ projectId }), + projectService(log).getOneOrThrow(projectId), + ]) + return [...new Set([...members, project.ownerId])] +} + async function resolveReadableProjectIds({ platformId, userId, projectId, log }: ResolveProjectsParams): Promise { const users = userService(log) const user = await users.getOneOrFail({ id: userId }) @@ -124,6 +215,30 @@ async function resolveReadableProjectIds({ platformId, userId, projectId, log }: .filter((id) => isNil(projectId) || id === projectId) } +function withoutToolSecrets(agent: Agent): Agent { + return { + ...agent, + draft: redactConfig(agent.draft), + published: isNil(agent.published) ? agent.published : redactConfig(agent.published), + } +} + +function redactConfig(config: AgentConfig): AgentConfig { + return { + ...config, + tools: config.tools.map((tool) => tool.type !== AgentToolType.MCP || tool.auth.type === McpAuthType.NONE + ? tool + : { ...tool, auth: { type: tool.auth.type } as typeof tool.auth }), + } +} + +function describePublished({ published }: { published: AgentConfig }): { publishedDigest: string, publishedToolNames: string[] } { + return { + publishedDigest: createHash('sha256').update(JSON.stringify(published)).digest('hex').slice(0, 16), + publishedToolNames: published.tools.map((tool) => tool.toolName), + } +} + function agentNotFound(id: ApId): ActivepiecesError { return new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, @@ -164,7 +279,22 @@ type ResolveProjectsParams = { type ResolveShareParams = { visibility: AgentVisibility - sharedWithUserIds?: UserId[] + requested?: UserId[] + stored: UserId[] projectId: ProjectId log: FastifyBaseLogger } + +type VisibilityParams = { + userId: UserId + prefix: 'agent.' | '' + isProjectAdmin: boolean +} + +type AssertShareParams = { + agent: Agent + request: UpdateAgentRequest + projectId: ProjectId + userId: UserId + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/ee/agent/agent-templates.ts b/packages/server/api/src/app/ee/agent/agent-templates.ts new file mode 100644 index 000000000000..dfbfea8ad960 --- /dev/null +++ b/packages/server/api/src/app/ee/agent/agent-templates.ts @@ -0,0 +1,68 @@ +import { AgentIcon, AgentTemplate, ColorName } from '@activepieces/shared' + +export const AGENT_TEMPLATES: readonly AgentTemplate[] = [ + { + id: 'launch-copy', + displayName: 'Launch copy', + description: 'Writes the announcement, in two versions, in your voice.', + icon: AgentIcon.SPARKLES, + color: ColorName.PURPLE, + instructions: 'You draft launch copy. If you were given no sample of the writing you should match, ask for one rather than inventing a house voice. Keep posts under 120 words, lead with what changed for the reader, and never invent a metric, a customer quote, or a release date. Always return two options with one line on how they differ.', + }, + { + id: 'research-analyst', + displayName: 'Research analyst', + description: 'Searches, reads, and returns a brief with sources.', + icon: AgentIcon.SEARCH, + color: ColorName.BLUE, + instructions: 'You research a question and return a short brief. Search before you answer, read more than one source, and say which claim came from where. If you cannot search, say so plainly and work only from what you were given, marking every claim you could not verify. Lead with the answer, then the evidence, then what is still uncertain. If the sources disagree, say so rather than picking one silently.', + }, + { + id: 'support-triage', + displayName: 'Support triage', + description: 'Reads a ticket, tags severity, and drafts the first reply.', + icon: AgentIcon.MESSAGE, + color: ColorName.ORANGE, + instructions: 'You triage incoming support tickets. Decide severity from customer impact, not from tone. Summarise the problem in one sentence, list what you would need to reproduce it, and draft a first reply that acknowledges the specific issue rather than thanking them for their patience. Never promise a fix date.', + }, + { + id: 'meeting-follow-up', + displayName: 'Meeting follow-up', + description: 'Turns notes into decisions, owners, and next steps.', + icon: AgentIcon.CALENDAR, + color: ColorName.GREEN, + instructions: 'You turn meeting notes into a follow-up. Separate decisions from discussion, and give every action an owner and a date. If an action has no owner in the notes, list it as unassigned rather than guessing. Keep it short enough to read on a phone.', + }, + { + id: 'sales-prospecting', + displayName: 'Sales prospecting', + description: 'Researches a lead and writes an opener worth answering.', + icon: AgentIcon.USERS, + color: ColorName.CYAN, + instructions: 'You research a lead and write a first-touch email. Find something specific and recent about their company, and open with that rather than with your product. Three sentences, one question, no adjectives about yourself. If you cannot search, or cannot find anything specific, say so instead of writing a generic opener.', + }, + { + id: 'content-repurposer', + displayName: 'Content repurposer', + description: 'Turns one long piece into posts for each channel.', + icon: AgentIcon.FILE, + color: ColorName.PINK, + instructions: 'You repurpose one long piece of content into shorter ones. Pull the ideas that stand alone, and rewrite each for its channel rather than truncating the original. Keep the author\'s claims exactly as they made them. Say which section each piece came from.', + }, + { + id: 'data-cleanup', + displayName: 'Data cleanup', + description: 'Flags duplicates and inconsistent formatting in a list.', + icon: AgentIcon.CHART, + color: ColorName.YELLOW, + instructions: 'You review messy records and report what needs fixing. Flag inconsistent formatting and likely duplicates rather than changing or merging anything yourself, and list every problem so it can be reviewed in one pass. When two records conflict, show both and ask which wins. Never delete or rewrite a record.', + }, + { + id: 'onboarding-buddy', + displayName: 'Onboarding buddy', + description: 'Answers new-hire questions from your own docs.', + icon: AgentIcon.BOOK, + color: ColorName.LAVENDER, + instructions: 'You answer questions from new team members. Answer from the documents you are given, quote the part you used, and link it. If you were given no documents, ask for them rather than answering from general knowledge. If the docs do not cover the question, say that and suggest who to ask rather than guessing at an answer that sounds plausible.', + }, +] diff --git a/packages/server/api/src/app/ee/authentication/enterprise-local-authn/enterprise-local-authn-service.ts b/packages/server/api/src/app/ee/authentication/enterprise-local-authn/enterprise-local-authn-service.ts index 4970961e7d40..2e243f9531a1 100644 --- a/packages/server/api/src/app/ee/authentication/enterprise-local-authn/enterprise-local-authn-service.ts +++ b/packages/server/api/src/app/ee/authentication/enterprise-local-authn/enterprise-local-authn-service.ts @@ -1,10 +1,10 @@ import { ActivepiecesError, ErrorCode, isNil, UserId } from '@activepieces/core-utils' import { ApplicationEvent, ApplicationEventName, OtpType, ResetPasswordRequestBody, UserIdentity, VerifyEmailRequestBody } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { otpService } from '../../../authentication/otp/otp-service' import { userIdentityService } from '../../../authentication/user-identity/user-identity-service' import { applicationEvents } from '../../../helper/application-events' import { userService } from '../../../user/user-service' -import { otpService } from '../otp/otp-service' export const enterpriseLocalAuthnService = (log: FastifyBaseLogger) => ({ async verifyEmail({ identityId, otp }: VerifyEmailRequestBody): Promise { diff --git a/packages/server/api/src/app/ee/authentication/otp/lib/otp-generator.ts b/packages/server/api/src/app/ee/authentication/otp/lib/otp-generator.ts deleted file mode 100644 index 1d7b7b4edac4..000000000000 --- a/packages/server/api/src/app/ee/authentication/otp/lib/otp-generator.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { randomUUID } from 'node:crypto' - -export const otpGenerator = { - generate(): string { - return randomUUID() - }, -} diff --git a/packages/server/api/src/app/ee/authentication/otp/otp-service.ts b/packages/server/api/src/app/ee/authentication/otp/otp-service.ts deleted file mode 100644 index aaa7759581cc..000000000000 --- a/packages/server/api/src/app/ee/authentication/otp/otp-service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { apId, PlatformId } from '@activepieces/core-utils' -import { OtpModel, OtpState, OtpType } from '@activepieces/shared' -import dayjs from 'dayjs' -import { FastifyBaseLogger } from 'fastify' -import { userIdentityService } from '../../../authentication/user-identity/user-identity-service' -import { repoFactory } from '../../../core/db/repo-factory' -import { emailService } from '../../helper/email/email-service' -import { otpGenerator } from './lib/otp-generator' -import { OtpEntity } from './otp-entity' - -const OTP_EXPIRATION_MS: Record = { - [OtpType.EMAIL_VERIFICATION]: 24 * 60 * 60 * 1000, - [OtpType.PASSWORD_RESET]: 10 * 60 * 1000, -} - -const repo = repoFactory(OtpEntity) - -export const otpService = (log: FastifyBaseLogger) => ({ - async createAndSend({ - platformId, - email, - type, - }: CreateParams): Promise { - const userIdentity = await userIdentityService(log).getIdentityByEmail(email) - if (!userIdentity) { - return - } - const existingOtp = await repo().findOneBy({ - identityId: userIdentity.id, - type, - }) - const existingOtpIsReusable = existingOtp && existingOtp.state === OtpState.PENDING && !otpIsExpired(existingOtp) - if (existingOtpIsReusable) { - await emailService(log).sendOtp({ - platformId, - userIdentity, - otp: existingOtp.value, - type, - }) - return - } - const newOtp: Omit = { - id: apId(), - updated: dayjs().toISOString(), - type, - identityId: userIdentity.id, - value: otpGenerator.generate(), - state: OtpState.PENDING, - } - await repo().upsert(newOtp, ['identityId', 'type']) - await emailService(log).sendOtp({ - platformId, - userIdentity, - otp: newOtp.value, - type: newOtp.type, - }) - }, - - async confirm({ identityId, type, value }: ConfirmParams): Promise { - const otp = await repo().findOneByOrFail({ - identityId, - type, - }) - const otpIsPending = otp.state === OtpState.PENDING - const otpMatches = otp.value === value - const verdict = !otpIsExpired(otp) && otpMatches && otpIsPending - if (verdict) { - await repo().update(otp.id, { - state: OtpState.CONFIRMED, - }) - } - - return verdict - }, -}) - -function otpIsExpired(otp: OtpModel): boolean { - return dayjs().diff(otp.updated, 'milliseconds') >= OTP_EXPIRATION_MS[otp.type] -} - -type CreateParams = { - platformId: PlatformId | null - email: string - type: OtpType -} - -type ConfirmParams = { - identityId: string - type: OtpType - value: string -} diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts index 364529d9c3f5..ff7e37d96134 100644 --- a/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts +++ b/packages/server/api/src/app/ee/helper/email/email-sender/email-sender.ts @@ -66,6 +66,10 @@ type ScimUserWelcomeTemplateData = BaseEmailTemplateData<'scim-user-welcome', { loginLink: string }> +type LoginCodeTemplateData = BaseEmailTemplateData<'login-code', { + code: string +}> + type ChatNotificationTemplateData = BaseEmailTemplateData<'chat-notification', { subject: string body: string @@ -86,6 +90,7 @@ export type EmailTemplateData = | ScimUserWelcomeTemplateData | ChatNotificationTemplateData | PlatformDeletedTemplateData + | LoginCodeTemplateData type SendArgs = { emails: string[] diff --git a/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts b/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts index 9124b5c954f6..94218b55345e 100644 --- a/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts +++ b/packages/server/api/src/app/ee/helper/email/email-sender/smtp-email-sender.ts @@ -138,6 +138,7 @@ const getEmailSubject = (templateName: EmailTemplateData['name'], vars: Record ({ }, async sendOtp({ platformId, userIdentity, otp, type }: SendOtpArgs): Promise { - if (EDITION_IS_NOT_PAID) { + if (EDITION_IS_NOT_PAID && type !== OtpType.EMAIL_LOGIN) { return } @@ -181,34 +181,10 @@ export const emailService = (log: FastifyBaseLogger) => ({ type, }, 'Sending OTP email') - const frontendPath = { - [OtpType.EMAIL_VERIFICATION]: 'verify-email', - [OtpType.PASSWORD_RESET]: 'reset-password', - } - - const setupLink = await domainHelper.getInternalUrl({ - path: frontendPath[type] + `?otpcode=${otp}&identityId=${userIdentity.id}`, - }) - - const otpToTemplate: Record = { - [OtpType.EMAIL_VERIFICATION]: { - name: 'verify-email', - vars: { - setupLink, - }, - }, - [OtpType.PASSWORD_RESET]: { - name: 'reset-password', - vars: { - setupLink, - }, - }, - } - await emailSender(log).send({ emails: [userIdentity.email], platformId: platformId ?? undefined, - templateData: otpToTemplate[type], + templateData: await otpTemplateData({ type, otp, identityId: userIdentity.id }), }) }, @@ -236,6 +212,29 @@ export const emailService = (log: FastifyBaseLogger) => ({ }, }) +async function otpTemplateData({ type, otp, identityId }: OtpTemplateDataParams): Promise { + switch (type) { + case OtpType.EMAIL_LOGIN: + return { name: 'login-code', vars: { code: otp } } + case OtpType.EMAIL_VERIFICATION: + return { + name: 'verify-email', + vars: { setupLink: await otpSetupLink({ path: 'verify-email', otp, identityId }) }, + } + case OtpType.PASSWORD_RESET: + return { + name: 'reset-password', + vars: { setupLink: await otpSetupLink({ path: 'reset-password', otp, identityId }) }, + } + } +} + +async function otpSetupLink({ path, otp, identityId }: OtpSetupLinkParams): Promise { + return domainHelper.getInternalUrl({ + path: `${path}?otpcode=${otp}&identityId=${identityId}`, + }) +} + async function getEntityNameForInvitation(userInvitation: UserInvitation, log: FastifyBaseLogger): Promise<{ name: string, role: string }> { switch (userInvitation.type) { case InvitationType.PLATFORM: { @@ -274,6 +273,18 @@ type SendProjectMemberAddedArgs = { userInvitation: UserInvitation } +type OtpTemplateDataParams = { + type: OtpType + otp: string + identityId: string +} + +type OtpSetupLinkParams = { + path: string + otp: string + identityId: string +} + type SendOtpArgs = { type: OtpType platformId: string | null diff --git a/packages/server/api/src/app/flags/flag.service.ts b/packages/server/api/src/app/flags/flag.service.ts index 2586e171f619..819d127a8ef2 100644 --- a/packages/server/api/src/app/flags/flag.service.ts +++ b/packages/server/api/src/app/flags/flag.service.ts @@ -4,6 +4,7 @@ import { ApEdition, ApFlagId, ExecutionMode, Flag } from '@activepieces/shared' import dayjs from 'dayjs' import { FastifyBaseLogger } from 'fastify' import { In } from 'typeorm' +import { turnstile } from '../authentication/lib/turnstile' import { repoFactory } from '../core/db/repo-factory' import { federatedAuthnService } from '../ee/authentication/federated-authn/federated-authn-service' import { smtpEmailSender } from '../ee/helper/email/email-sender/smtp-email-sender' @@ -285,6 +286,12 @@ export const flagService = (log: FastifyBaseLogger) => ({ created, updated, }, + { + id: ApFlagId.TURNSTILE_SITE_KEY, + value: turnstile.siteKey() ?? null, + created, + updated, + }, { id: ApFlagId.PGVECTOR_AVAILABLE, value: await knowledgeBaseSchema.isVectorExtensionInstalled(), diff --git a/packages/server/api/src/app/helper/system-validator.ts b/packages/server/api/src/app/helper/system-validator.ts index 61eb8a9cc748..c7486a140b5e 100644 --- a/packages/server/api/src/app/helper/system-validator.ts +++ b/packages/server/api/src/app/helper/system-validator.ts @@ -50,6 +50,7 @@ const systemPropValidators: { [key in SystemProp]: (value: string) => true | string } = { // AppSystemProp + [AppSystemProp.ALLOW_DISPOSABLE_EMAILS]: booleanValidator, [AppSystemProp.ALLOW_OPEN_SIGN_UP]: booleanValidator, [AppSystemProp.EXECUTION_MODE]: enumValidator(Object.values(ExecutionMode)), [AppSystemProp.SKIP_PROJECT_LIMITS_CHECK]: booleanValidator, @@ -76,6 +77,8 @@ const systemPropValidators: { [AppSystemProp.LOKI_USERNAME]: stringValidator, [AppSystemProp.BETTERSTACK_TOKEN]: stringValidator, + [AppSystemProp.TURNSTILE_SECRET_KEY]: stringValidator, + [AppSystemProp.TURNSTILE_SITE_KEY]: stringValidator, [AppSystemProp.BETTERSTACK_HOST]: stringValidator, [AppSystemProp.OTEL_ENABLED]: booleanValidator, [AppSystemProp.OTEL_QUEUE_METRICS_ENABLED]: booleanValidator, @@ -93,6 +96,7 @@ const systemPropValidators: { [AppSystemProp.API_RATE_LIMIT_AUTHN_ENABLED]: booleanValidator, [AppSystemProp.API_RATE_LIMIT_AUTHN_MAX]: numberValidator, [AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW]: stringValidator, + [AppSystemProp.API_RATE_LIMIT_EMAIL_CODE_MAX]: numberValidator, [AppSystemProp.CLIENT_REAL_IP_HEADER]: stringValidator, [AppSystemProp.CLOUD_AUTH_ENABLED]: booleanValidator, [AppSystemProp.CONFIG_PATH]: stringValidator, diff --git a/packages/server/api/src/app/helper/system/system-props.ts b/packages/server/api/src/app/helper/system/system-props.ts index b45042778a58..5730e1ad9e17 100644 --- a/packages/server/api/src/app/helper/system/system-props.ts +++ b/packages/server/api/src/app/helper/system/system-props.ts @@ -4,6 +4,7 @@ import { environmentMigrations } from '@activepieces/server-utils' export type SystemProp = AppSystemProp export enum AppSystemProp { + ALLOW_DISPOSABLE_EMAILS = 'ALLOW_DISPOSABLE_EMAILS', ALLOW_OPEN_SIGN_UP = 'ALLOW_OPEN_SIGN_UP', ALLOWED_EMBED_ORIGINS = 'ALLOWED_EMBED_ORIGINS', API_KEY = 'API_KEY', @@ -12,6 +13,7 @@ export enum AppSystemProp { API_RATE_LIMIT_AUTHN_ENABLED = 'API_RATE_LIMIT_AUTHN_ENABLED', API_RATE_LIMIT_AUTHN_MAX = 'API_RATE_LIMIT_AUTHN_MAX', API_RATE_LIMIT_AUTHN_WINDOW = 'API_RATE_LIMIT_AUTHN_WINDOW', + API_RATE_LIMIT_EMAIL_CODE_MAX = 'API_RATE_LIMIT_EMAIL_CODE_MAX', APP_WEBHOOK_SECRETS = 'APP_WEBHOOK_SECRETS', APPSUMO_TOKEN = 'APPSUMO_TOKEN', AUTUMN_CONSOLE_URL = 'AUTUMN_CONSOLE_URL', @@ -117,6 +119,8 @@ export enum AppSystemProp { SMTP_TLS_REJECT_UNAUTHORIZED = 'SMTP_TLS_REJECT_UNAUTHORIZED', SMTP_USERNAME = 'SMTP_USERNAME', TELEMETRY_ENABLED = 'TELEMETRY_ENABLED', + TURNSTILE_SECRET_KEY = 'TURNSTILE_SECRET_KEY', + TURNSTILE_SITE_KEY = 'TURNSTILE_SITE_KEY', TOOL_SEARCH_ENABLED = 'TOOL_SEARCH_ENABLED', TRIGGER_DEFAULT_POLL_INTERVAL = 'TRIGGER_DEFAULT_POLL_INTERVAL', TRIGGER_HOOKS_TIMEOUT_SECONDS = 'TRIGGER_HOOKS_TIMEOUT_SECONDS', diff --git a/packages/server/api/src/app/helper/system/system.ts b/packages/server/api/src/app/helper/system/system.ts index 9ef05cbd6b91..fbd3a01c6b93 100644 --- a/packages/server/api/src/app/helper/system/system.ts +++ b/packages/server/api/src/app/helper/system/system.ts @@ -14,6 +14,7 @@ const systemPropDefaultValues: Partial> = { [AppSystemProp.API_RATE_LIMIT_AUTHN_ENABLED]: 'true', [AppSystemProp.API_RATE_LIMIT_AUTHN_MAX]: '50', [AppSystemProp.API_RATE_LIMIT_AUTHN_WINDOW]: '1 minute', + [AppSystemProp.API_RATE_LIMIT_EMAIL_CODE_MAX]: '5', [AppSystemProp.WORKERS]: '1', [AppSystemProp.CLIENT_REAL_IP_HEADER]: 'x-real-ip', [AppSystemProp.CLOUD_AUTH_ENABLED]: 'true', @@ -33,6 +34,7 @@ const systemPropDefaultValues: Partial> = { [AppSystemProp.WEBHOOK_TIMEOUT_SECONDS]: '30', [AppSystemProp.LOAD_TRANSLATIONS_FOR_DEV_PIECES]: 'false', [AppSystemProp.LOG_LEVEL]: 'info', + [AppSystemProp.ALLOW_DISPOSABLE_EMAILS]: 'false', [AppSystemProp.LOG_PRETTY]: 'false', [AppSystemProp.S3_USE_SIGNED_URLS]: 'false', [AppSystemProp.MAX_FILE_SIZE_MB]: '25', diff --git a/packages/server/api/src/app/helper/telemetry.utils.ts b/packages/server/api/src/app/helper/telemetry.utils.ts index f0cae7f4cb31..12aacd852334 100644 --- a/packages/server/api/src/app/helper/telemetry.utils.ts +++ b/packages/server/api/src/app/helper/telemetry.utils.ts @@ -61,6 +61,9 @@ export const telemetry = (log: FastifyBaseLogger) => ({ const project = await projectService(log).getOne(projectId) return this.trackUser(project!.ownerId, event, { platform: project!.platformId }) }, + async trackIdentity(identityId: string, event: TelemetryEvent): Promise { + return this.trackUser(identityId, event) + }, isEnabled: () => telemetryEnabled, async trackUser(userId: UserId, event: TelemetryEvent, groups?: Record): Promise { if (!telemetryEnabled) { diff --git a/packages/server/api/src/app/platform/billing-and-telemetry.ts b/packages/server/api/src/app/platform/billing-and-telemetry.ts index c0950e754e1f..c18b895536ca 100644 --- a/packages/server/api/src/app/platform/billing-and-telemetry.ts +++ b/packages/server/api/src/app/platform/billing-and-telemetry.ts @@ -10,7 +10,7 @@ export async function trackBillingAndSendTelemetry({ log, licenseKey, credits, a const appSumoEvent: TrackFeatureParams | undefined = isNil(appSumo) ? undefined : { ...appSumo, featureId: ConsumableFeatureId.APP_SUMO_AI_CREDITS } const tracked = isNil(appSumoEvent) ? [creditsEvent] : [creditsEvent, appSumoEvent] await Promise.all(tracked.map((params) => provider.trackFeature(params))) - if (isNil(licenseKey) || licenseKey.length === 0) { + if (isNil(licenseKey) || licenseKey.length === 0 || isNil(telemetry)) { return } captureBillingEvent({ licenseKey, ...telemetry }) @@ -21,5 +21,5 @@ type TrackBillingAndSendTelemetryParams = { licenseKey: string | null | undefined credits: TrackCreditsParams appSumo?: TrackAppSumoAiUsageParams - telemetry: BillingEventPayload + telemetry?: BillingEventPayload } diff --git a/packages/server/api/src/app/platform/billing-provider.ts b/packages/server/api/src/app/platform/billing-provider.ts index 827bdf83d0a2..e5f57786c052 100644 --- a/packages/server/api/src/app/platform/billing-provider.ts +++ b/packages/server/api/src/app/platform/billing-provider.ts @@ -143,6 +143,7 @@ export enum CreditUsageSource { FLOW_RUN = 'flow_run', AI = 'ai', CHAT = 'chat', + AGENT_DRAFT = 'agent_draft', } type ToFlowRunCreditPropertiesParams = { @@ -180,6 +181,10 @@ export type FlowRunCreditConsumptionProperties = CreditConsumptionPropertiesBase environment: string } +export type AgentDraftCreditConsumptionProperties = CreditConsumptionPropertiesBase & { + provider: string | null +} + export type AiCreditConsumptionProperties = FlowRunCreditConsumptionProperties & { messages: number toolCalls: number @@ -213,8 +218,10 @@ export type TrackCreditsParams = | (TrackUsageParamsBase & { source: CreditUsageSource.FLOW_RUN, properties: FlowRunCreditConsumptionProperties }) | (TrackUsageParamsBase & { source: CreditUsageSource.AI, properties: AiCreditConsumptionProperties }) | (TrackUsageParamsBase & { source: CreditUsageSource.CHAT, properties: ChatCreditConsumptionProperties }) + | (TrackUsageParamsBase & { source: CreditUsageSource.AGENT_DRAFT, properties: AgentDraftCreditConsumptionProperties }) export type TrackAppSumoAiUsageParams = TrackUsageParamsBase & ( + { source: CreditUsageSource.AGENT_DRAFT, properties: AgentDraftCreditConsumptionProperties } | { source: CreditUsageSource.AI, properties: AiCreditConsumptionProperties } | { source: CreditUsageSource.CHAT, properties: ChatAppSumoConsumptionProperties } ) diff --git a/packages/server/api/src/app/platform/platform.controller.ts b/packages/server/api/src/app/platform/platform.controller.ts index 16db641b79cc..8c4cd68b023b 100644 --- a/packages/server/api/src/app/platform/platform.controller.ts +++ b/packages/server/api/src/app/platform/platform.controller.ts @@ -35,11 +35,14 @@ export const platformController: FastifyPluginAsyncZod = async (app) => { const identityId = isOnboarding ? req.principal.id : (await userService(req.log).getOneOrFail({ id: req.principal.id })).identityId - return platformService(req.log).createPlatformWithProject({ + const { response } = await platformService(req.log).createPlatformWithProject({ identityId, name: req.body.name, invalidatePreviousTokens: isOnboarding, + isFirstPlatform: isOnboarding, + callerTokenVersion: req.principal.type === PrincipalType.ONBOARDING ? req.principal.tokenVersion : undefined, }) + return response }) app.post('/:id', UpdatePlatformRequest, async (req, _res) => { diff --git a/packages/server/api/src/app/platform/platform.service.ts b/packages/server/api/src/app/platform/platform.service.ts index 46f8f6fe8df3..203ade2455c7 100644 --- a/packages/server/api/src/app/platform/platform.service.ts +++ b/packages/server/api/src/app/platform/platform.service.ts @@ -1,10 +1,11 @@ import { ActivepiecesError, apId, ErrorCode, isNil, PlatformId, spreadIfDefined, spreadIfNotUndefined, tryCatch, UserId } from '@activepieces/core-utils' -import { ApEdition, AuthenticationResponse, OPEN_SOURCE_PLAN, Platform, PlatformPlanLimits, PlatformRole, PlatformUsage, PlatformWithoutFederatedAuth, PlatformWithoutSensitiveData, ProjectType, SsoDomainVerification, SsoDomainVerificationStatus, UpdatePlatformRequestBody, UserStatus } from '@activepieces/shared' +import { ApEdition, AuthenticationResponse, OPEN_SOURCE_PLAN, Platform, PlatformPlanLimits, PlatformRole, PlatformUsage, PlatformWithoutFederatedAuth, PlatformWithoutSensitiveData, ProjectType, SsoDomainVerification, SsoDomainVerificationStatus, UpdatePlatformRequestBody, User, UserStatus } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { nanoid } from 'nanoid' import { authenticationUtils } from '../authentication/authentication-utils' import { userIdentityRepository, userIdentityService } from '../authentication/user-identity/user-identity-service' import { repoFactory } from '../core/db/repo-factory' +import { distributedLock } from '../database/redis-connections' import { invalidateSamlClientCache } from '../ee/authentication/saml-authn/saml-client' import { platformPlanService } from '../ee/platform/platform-plan/platform-plan.service' import { defaultTheme } from '../flags/theme' @@ -75,33 +76,49 @@ export const platformService = (log: FastifyBaseLogger) => ({ log.info({ platform: { id: savedPlatform.id }, ownerId }, 'Platform created') return stripFederatedAuth(savedPlatform) }, - async createPlatformWithProject({ identityId, name, invalidatePreviousTokens }: CreatePlatformWithProjectParams): Promise { - const newUser = await userService(log).create({ - identityId, - platformRole: PlatformRole.ADMIN, - platformId: null, - }) - const platform = await this.create({ ownerId: newUser.id, name }) - const defaultProject = await projectService(log).create({ - displayName: `${name}'s Project`, - ownerId: newUser.id, - platformId: platform.id, - type: ProjectType.PERSONAL, - }) - if (invalidatePreviousTokens) { - await userIdentityRepository().update(identityId, { - tokenVersion: nanoid(), - }) - } - await authenticationUtils(log).sendTelemetry({ - identity: await userIdentityService(log).getOneOrFail({ id: identityId }), - user: newUser, - projectId: defaultProject.id, - }) - return authenticationUtils(log).getProjectAndToken({ - userId: newUser.id, - platformId: platform.id, - projectId: defaultProject.id, + async createPlatformWithProject({ identityId, name, invalidatePreviousTokens, isFirstPlatform, callerTokenVersion, beforeProvision }: CreatePlatformWithProjectParams): Promise { + return distributedLock(log).runExclusive({ + key: `create-platform-${identityId}`, + timeoutInSeconds: 30, + fn: async () => { + const existingUsers = isFirstPlatform ? await userService(log).getByIdentityId({ identityId }) : [] + const provisionedOwner = findProvisionedOwner(existingUsers) + const platformAlreadyProvisioned = !isNil(provisionedOwner) + if (platformAlreadyProvisioned) { + return resumeProvisionedPlatform({ owner: provisionedOwner, identityId, name, invalidatePreviousTokens, callerTokenVersion, log }) + } + const ownerWithoutPlatform = existingUsers.find((user) => isNil(user.platformId)) + const unlinkedPlatform = isNil(ownerWithoutPlatform) ? null : await platformRepo().findOneBy({ ownerId: ownerWithoutPlatform.id }) + const provisioningStoppedBeforeLinkingTheOwner = !isNil(ownerWithoutPlatform) && !isNil(unlinkedPlatform) + if (provisioningStoppedBeforeLinkingTheOwner) { + await beforeProvision?.() + return linkOwnerToPlatform({ ownerId: ownerWithoutPlatform.id, platformId: unlinkedPlatform.id, identityId, name, invalidatePreviousTokens, log }) + } + await beforeProvision?.() + const owner = ownerWithoutPlatform + ?? await userService(log).create({ + identityId, + platformRole: PlatformRole.ADMIN, + platformId: null, + }) + const platform = await this.create({ ownerId: owner.id, name }) + const personalProject = await projectService(log).create({ + displayName: personalProjectName(name), + ownerId: owner.id, + platformId: platform.id, + type: ProjectType.PERSONAL, + }) + if (invalidatePreviousTokens) { + await rotateTokenVersion(identityId) + } + await reportSignup({ identityId, user: owner, projectId: personalProject.id, log }) + const response = await authenticationUtils(log).getProjectAndToken({ + userId: owner.id, + platformId: platform.id, + projectId: personalProject.id, + }) + return { response, provisioned: true } + }, }) }, async getAll(): Promise { @@ -251,6 +268,92 @@ export const platformService = (log: FastifyBaseLogger) => ({ }, }) +function findProvisionedOwner(users: User[]): PlatformOwner | undefined { + return users.find((user): user is PlatformOwner => !isNil(user.platformId)) +} + +async function resumeProvisionedPlatform({ owner, identityId, name, invalidatePreviousTokens, callerTokenVersion, log }: ResumeProvisionedPlatformParams): Promise { + const identity = await userIdentityService(log).getOneOrFail({ id: identityId }) + const earlierAttemptNeverRotated = isSameTokenVersion(identity.tokenVersion, callerTokenVersion) + const response = await finishExistingPlatform({ + user: owner, + platformId: owner.platformId, + name, + invalidatePreviousTokens: invalidatePreviousTokens && earlierAttemptNeverRotated, + identityId, + log, + }) + return { response, provisioned: false } +} + +async function linkOwnerToPlatform({ ownerId, platformId, identityId, name, invalidatePreviousTokens, log }: LinkOwnerToPlatformParams): Promise { + await userService(log).addOwnerToPlatform({ id: ownerId, platformId }) + const owner = await userService(log).getOneOrFail({ id: ownerId }) + const response = await finishExistingPlatform({ + user: owner, + platformId, + name, + invalidatePreviousTokens, + identityId, + log, + }) + if (!isNil(response.projectId)) { + await reportSignup({ identityId, user: owner, projectId: response.projectId, log }) + } + return { response, provisioned: true } +} + +async function reportSignup({ identityId, user, projectId, log }: ReportSignupParams): Promise { + await authenticationUtils(log).sendTelemetry({ + identity: await userIdentityService(log).getOneOrFail({ id: identityId }), + user, + projectId, + }) +} + +function isSameTokenVersion(current: string | undefined, caller: string | undefined): boolean { + const neitherHasBeenRotated = isNil(current) && isNil(caller) + return neitherHasBeenRotated || current === caller +} + +async function rotateTokenVersion(identityId: string): Promise { + await userIdentityRepository().update(identityId, { + tokenVersion: nanoid(), + }) +} + +function personalProjectName(platformName: string): string { + const noun = ' Platform' + if (platformName.endsWith(noun)) { + return `${platformName.slice(0, -noun.length)} Project` + } + return /['’]s$/.test(platformName) ? `${platformName} Project` : `${platformName}'s Project` +} + +async function finishExistingPlatform({ user, platformId, name, invalidatePreviousTokens, identityId, log }: FinishExistingPlatformParams): Promise { + const hasProjects = await projectService(log).userHasProjects({ + platformId, + userId: user.id, + isPrivileged: userService(log).isUserPrivileged(user), + }) + const project = hasProjects + ? null + : await projectService(log).create({ + displayName: personalProjectName(name), + ownerId: user.id, + platformId, + type: ProjectType.PERSONAL, + }) + if (invalidatePreviousTokens) { + await rotateTokenVersion(identityId) + } + return authenticationUtils(log).getProjectAndToken({ + userId: user.id, + platformId, + projectId: project?.id ?? null, + }) +} + async function getUsage(log: FastifyBaseLogger, platform: PlatformWithoutFederatedAuth): Promise { const edition = system.getEdition() if (edition === ApEdition.COMMUNITY) { @@ -311,10 +414,52 @@ type UpdateParams = UpdatePlatformRequestBody & { ssoDomainVerification?: SsoDomainVerification | null } +type CreatePlatformWithProjectResult = { + response: AuthenticationResponse + provisioned: boolean +} + type CreatePlatformWithProjectParams = { identityId: string name: string invalidatePreviousTokens: boolean + isFirstPlatform: boolean + callerTokenVersion: string | undefined + beforeProvision?: () => Promise +} + +type PlatformOwner = User & { + platformId: PlatformId +} +type ResumeProvisionedPlatformParams = { + owner: PlatformOwner + identityId: string + name: string + invalidatePreviousTokens: boolean + callerTokenVersion: string | undefined + log: FastifyBaseLogger +} +type LinkOwnerToPlatformParams = { + ownerId: UserId + platformId: PlatformId + identityId: string + name: string + invalidatePreviousTokens: boolean + log: FastifyBaseLogger +} +type FinishExistingPlatformParams = { + user: User + platformId: PlatformId + name: string + invalidatePreviousTokens: boolean + identityId: string + log: FastifyBaseLogger +} +type ReportSignupParams = { + identityId: string + user: User + projectId: string + log: FastifyBaseLogger } type ListPlatformsForIdentityParams = { diff --git a/packages/server/api/src/assets/emails/login-code.html b/packages/server/api/src/assets/emails/login-code.html new file mode 100644 index 000000000000..b9a2328b599a --- /dev/null +++ b/packages/server/api/src/assets/emails/login-code.html @@ -0,0 +1,79 @@ + + + + + + + + + + + Your sign-in code 🔑 + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{platformName}} +
+ Your sign-in code 🔑 +
+ Enter this code to finish signing in to {{platformName}}. It expires in 10 minutes. +
+ + + + + + +
+ {{code}} +
+
+ If you didn't try to sign in, you can ignore this email. Nobody can access your account without this code. +
+ {{> footer}} +
+ +
+ + diff --git a/packages/server/api/src/assets/prompts/agent-draft-prompt.md b/packages/server/api/src/assets/prompts/agent-draft-prompt.md new file mode 100644 index 000000000000..2ca24ead41c1 --- /dev/null +++ b/packages/server/api/src/assets/prompts/agent-draft-prompt.md @@ -0,0 +1,14 @@ +You turn one sentence into an agent definition. + +displayName is two or three words naming the job a person would recognise, never the words agent, assistant, or AI. + +description is one sentence of at most twelve words, third person, starting with a verb. + +icon: pick the one that matches the work, and only use bot when nothing else fits. + +instructions is three to five sentences addressed to the agent as "You ...". It states how to decide rather than only what to do; it states one thing the agent must never do; and it states what to do when the input it needs is missing, which is to ask rather than guess. + +The agent can fetch a URL and scrape a page, and can usually search the web. It has no other tools unless someone adds them later, so never tell it to send, post, or update anything, and give it a fallback for when it cannot search. + +Example. Sentence: "help me follow up after customer calls" +{"displayName":"Meeting follow-up","description":"Turns notes into decisions, owners, and next steps.","icon":"calendar","color":"GREEN","instructions":"You turn meeting notes into a follow-up. Separate decisions from discussion, and give every action an owner and a date. If an action has no owner in the notes, list it as unassigned rather than guessing. If you were given no notes, ask for them instead of inventing a summary. Keep it short enough to read on a phone."} diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index 0aa08a5303d8..e5ffb7bc2635 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -367,6 +367,7 @@ export const createMockOtp = (otp?: Partial): OtpModel => { value: otp?.value ?? faker.number.int({ min: 100000, max: 999999 }).toString(), state: otp?.state ?? faker.helpers.enumValue(OtpState), + attempts: otp?.attempts ?? 0, } } diff --git a/packages/server/api/test/integration/ce/authentication/otp-service.test.ts b/packages/server/api/test/integration/ce/authentication/otp-service.test.ts new file mode 100644 index 000000000000..539320446a91 --- /dev/null +++ b/packages/server/api/test/integration/ce/authentication/otp-service.test.ts @@ -0,0 +1,156 @@ +import { OtpType } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { otpService } from '../../../../src/app/authentication/otp/otp-service' +import { databaseConnection } from '../../../../src/app/database/database-connection' +import { createMockUserIdentity } from '../../../helpers/mocks' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance | null = null + +const EMAIL = 'otp.budget@example.com' +const MAX_ATTEMPTS = 5 + +async function seedIdentityWithCode(): Promise { + const identity = createMockUserIdentity({ email: EMAIL, verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + await otpService(app!.log).createAndSend({ + platformId: null, + email: EMAIL, + type: OtpType.EMAIL_LOGIN, + }) + const otp = await databaseConnection().getRepository('otp').findOneBy({ + identityId: identity.id, + type: OtpType.EMAIL_LOGIN, + }) + return otp!.value +} + +async function currentOtp() { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + return databaseConnection().getRepository('otp').findOneBy({ + identityId: identity!.id, + type: OtpType.EMAIL_LOGIN, + }) +} + +async function confirmCode(value: string): Promise { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + return otpService(app!.log).confirm({ + identityId: identity!.id, + type: OtpType.EMAIL_LOGIN, + value, + }) +} + +function wrongVersionOf(value: string): string { + const shifted = (Number.parseInt(value, 10) + 1) % 1000000 + return shifted.toString().padStart(6, '0') +} + +async function sendCode(): Promise { + await otpService(app!.log).createAndSend({ + platformId: null, + email: EMAIL, + type: OtpType.EMAIL_LOGIN, + }) +} + +async function backdateCode(minutesAgo: number): Promise { + const otp = await currentOtp() + const sentAt = new Date(Date.now() - minutesAgo * 60 * 1000) + await databaseConnection().getRepository('otp') + .query('UPDATE "otp" SET "updated" = $1 WHERE "id" = $2', [sentAt.toISOString(), otp!.id]) + return sentAt +} + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +beforeEach(async () => { + await databaseConnection().getRepository('otp').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user_identity').createQueryBuilder().delete().execute() +}) + +describe('otpService#createAndSend', () => { + it('re-sends the code already in flight instead of minting a second one', async () => { + const issued = await seedIdentityWithCode() + + await sendCode() + + expect((await currentOtp())!.value).toBe(issued) + }) + + it('mints a fresh code once the one in flight has expired', async () => { + const issued = await seedIdentityWithCode() + await backdateCode(11) + + await sendCode() + + expect((await currentOtp())!.value).not.toBe(issued) + }) +}) + +describe('otpService#confirm', () => { + it('accepts the correct code and consumes it', async () => { + const value = await seedIdentityWithCode() + + expect(await confirmCode(value)).toBe(true) + expect(await currentOtp()).toBeNull() + }) + + it('accepts the correct code exactly once', async () => { + const value = await seedIdentityWithCode() + await confirmCode(value) + + expect(await confirmCode(value)).toBe(false) + }) + + it('refuses a wrong code and spends one attempt', async () => { + const value = await seedIdentityWithCode() + + expect(await confirmCode(wrongVersionOf(value))).toBe(false) + expect((await currentOtp())!.attempts).toBe(1) + }) + + it('refuses a correct code once the attempt budget is already spent', async () => { + const value = await seedIdentityWithCode() + const otp = await currentOtp() + await databaseConnection().getRepository('otp').update(otp!.id, { attempts: MAX_ATTEMPTS }) + + const accepted = await confirmCode(value) + + expect(accepted).toBe(false) + }) + + it('throws the code away on the attempt that exhausts the budget', async () => { + const value = await seedIdentityWithCode() + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + await confirmCode(wrongVersionOf(value)) + } + + expect(await currentOtp()).toBeNull() + expect(await confirmCode(value)).toBe(false) + }) + + it('refuses a correct code that has outlived its ten minutes', async () => { + const value = await seedIdentityWithCode() + await backdateCode(11) + + expect(await confirmCode(value)).toBe(false) + }) + + it('does not extend the life of a code by guessing at it', async () => { + const value = await seedIdentityWithCode() + const backdated = await backdateCode(9) + + await confirmCode(wrongVersionOf(value)) + + expect(new Date((await currentOtp())!.updated).getTime()).toBe(backdated.getTime()) + }) +}) diff --git a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts new file mode 100644 index 000000000000..140731c9fb3e --- /dev/null +++ b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts @@ -0,0 +1,390 @@ +import { apId } from '@activepieces/core-utils' +import { OtpState, OtpType, PlatformRole, UserIdentityProvider, UserStatus } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { passwordHasher } from '../../../../src/app/authentication/lib/password-hasher' +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 { 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' + +let app: FastifyInstance | null = null + +const EMAIL = 'ahmad.tash@example.com' + +let callers = 0 + +async function requestCode(email: string): Promise { + callers += 1 + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + headers: { 'x-real-ip': `10.0.${Math.floor(callers / 256)}.${callers % 256}` }, + body: { email }, + }) + return response?.statusCode +} + +async function verifyCode({ email, code }: { email: string, code: string }) { + return app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/verify', + body: { email, code }, + }) +} + +function wrongCodeFor(code: string): string { + const shifted = (Number.parseInt(code, 10) + 1) % 1000000 + return shifted.toString().padStart(6, '0') +} + +async function storedIdentity(email: string) { + return databaseConnection().getRepository('user_identity').findOneBy({ email }) +} + +async function storedOtp(email: string) { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ 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() +}) + +beforeEach(async () => { + await databaseConnection().getRepository('flag').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('otp').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('project').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('platform').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user_identity').createQueryBuilder().delete().execute() +}) + +describe('Passwordless Authentication API', () => { + describe('Request code endpoint', () => { + it('creates an unverified identity and issues a 6 digit code', async () => { + const statusCode = await requestCode(EMAIL) + + expect(statusCode).toBe(StatusCodes.NO_CONTENT) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.verified).toBe(false) + expect(identity?.firstName).toBe('Ahmad') + + const otp = await storedOtp(EMAIL) + expect(otp?.value).toMatch(/^[0-9]{6}$/) + expect(otp?.state).toBe(OtpState.PENDING) + expect(otp?.attempts).toBe(0) + }) + + it('seeds the name from the email local part until the name step runs', async () => { + await requestCode(EMAIL) + + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.firstName).toBe('Ahmad') + expect(identity?.lastName).toBe('') + }) + + it('refuses a throwaway address and creates nothing', async () => { + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + body: { email: 'someone@mailinator.com' }, + }) + + expect(response?.statusCode).not.toBe(StatusCodes.NO_CONTENT) + expect(response?.json()?.code).toBe('DOMAIN_NOT_ALLOWED') + const identity = await databaseConnection().getRepository('user_identity') + .findOneBy({ email: 'someone@mailinator.com' }) + expect(identity).toBeNull() + }) + + it('lets an invited member through even on a throwaway domain', async () => { + const invited = 'guest@mailinator.com' + await databaseConnection().getRepository('user_invitation').save({ + id: apId(), + email: invited, + type: 'PLATFORM', + platformId: apId(), + status: 'ACCEPTED', + platformRole: PlatformRole.MEMBER, + }) + + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + body: { email: invited }, + }) + + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT) + }) + + it('issues a code with no captcha token when no challenge is configured', async () => { + const statusCode = await requestCode(EMAIL) + + expect(statusCode).toBe(StatusCodes.NO_CONTENT) + expect((await storedOtp(EMAIL))?.value).toMatch(/^[0-9]{6}$/) + }) + + it('does not set the USER_CREATED flag before a code is verified', async () => { + await requestCode(EMAIL) + + const flag = await databaseConnection().getRepository('flag').findOneBy({ id: 'USER_CREATED' }) + expect(flag).toBeNull() + }) + + it('answers alike for an unknown address, revealing nothing', async () => { + const first = await requestCode(EMAIL) + const second = await requestCode('someone-else@example.com') + + expect(first).toBe(StatusCodes.NO_CONTENT) + expect(second).toBe(StatusCodes.NO_CONTENT) + }) + + it('re-sends the same code instead of minting a new one', async () => { + await requestCode(EMAIL) + const issued = await storedOtp(EMAIL) + + await requestCode(EMAIL) + const afterResend = await storedOtp(EMAIL) + + expect(afterResend?.value).toBe(issued?.value) + }) + }) + + describe('Verify code endpoint', () => { + it('signs in, verifies the identity and consumes the code', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body = response?.json() + expect(body?.email).toBe(EMAIL) + expect(body?.verified).toBe(true) + expect(body?.token).toBeDefined() + expect(await storedOtp(EMAIL)).toBeNull() + }) + + it('discards a password planted on the address before its owner proved the inbox', async () => { + const plantedPassword = 'PlantedPassword123!' + await userIdentityService(app!.log).create({ + email: EMAIL, + password: plantedPassword, + firstName: 'Ahmad', + lastName: '', + trackEvents: true, + newsLetter: false, + provider: UserIdentityProvider.EMAIL, + verified: false, + }) + const planted = await storedIdentity(EMAIL) + expect(await passwordHasher.compare(plantedPassword, planted!.password)).toBe(true) + + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const afterVerification = await storedIdentity(EMAIL) + expect(afterVerification!.verified).toBe(true) + expect(await passwordHasher.compare(plantedPassword, afterVerification!.password)).toBe(false) + }) + + it('hands a brand-new member a pre-platform session so the name step can run', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body = response?.json() + expect(body?.platformId).toBeNull() + expect(body?.projectId).toBeNull() + expect(body?.token).toBeDefined() + expect(await databaseConnection().getRepository('platform').count()).toBe(0) + }) + + it('creates the platform from the name once the name step completes', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName: 'Ahmad Bin Tash' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const body = response?.json() + expect(body?.projectId).not.toBeNull() + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.firstName).toBe('Ahmad') + expect(identity?.lastName).toBe('Bin Tash') + const platform = await databaseConnection().getRepository('platform').findOneBy({ id: body?.platformId }) + expect(platform?.name).toBe("Ahmad's Platform") + const project = await databaseConnection().getRepository('project').findOneBy({ platformId: body?.platformId }) + expect(project?.displayName).toBe("Ahmad's Project") + }) + + it('consumes one code exactly once, even when two confirmations race it', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + const confirm = () => otpService(app!.log).confirm({ + identityId: identity!.id, + type: OtpType.EMAIL_LOGIN, + value: otp!.value, + }) + + const verdicts = await Promise.all([confirm(), confirm()]) + + expect(verdicts.filter((verdict) => verdict)).toHaveLength(1) + }) + + it('creates one platform for one identity, even when the name step is submitted twice', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + const completeSignUp = () => app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName: 'Ahmad Bin Tash' }, + }) + + const first = await completeSignUp() + const second = await completeSignUp() + + expect(first?.statusCode).toBe(StatusCodes.OK) + expect(second?.statusCode).toBe(StatusCodes.OK) + expect(second?.json()?.platformId).toBe(first?.json()?.platformId) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + expect(await databaseConnection().getRepository('project').count()).toBe(1) + expect(await databaseConnection().getRepository('user').count()).toBe(1) + }) + + it('creates one platform even when the other onboarding route races the name step', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + + const viaNameStep = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName: 'Ahmad Bin Tash' }, + }) + const viaPlatformRoute = await app?.inject({ + method: 'POST', + url: '/api/v1/platforms', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { name: 'Ahmad' }, + }) + + expect(viaNameStep?.statusCode).toBe(StatusCodes.OK) + expect(viaPlatformRoute?.statusCode).toBe(StatusCodes.OK) + expect(viaPlatformRoute?.json()?.platformId).toBe(viaNameStep?.json()?.platformId) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + expect(await databaseConnection().getRepository('user').count()).toBe(1) + }) + + it('does not rename an account whose chosen name matches its address', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + const complete = (fullName: string) => app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName }, + }) + await complete('Ahmad') + + await complete('Someone Else') + + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.firstName).toBe('Ahmad') + expect(identity?.lastName).toBe('') + }) + + it('does not rename the account when completion is replayed', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + const onboarding = await verifyCode({ email: EMAIL, code: otp!.value }) + const onboardingToken = onboarding?.json()?.token + const complete = (fullName: string) => app?.inject({ + method: 'POST', + url: '/api/v1/authentication/complete-sign-up', + headers: { authorization: `Bearer ${onboardingToken}` }, + body: { fullName }, + }) + await complete('Ahmad Tash') + + const replay = await complete('Someone Else') + + expect(replay?.statusCode).toBe(StatusCodes.OK) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + expect(identity?.firstName).toBe('Ahmad') + expect(identity?.lastName).toBe('Tash') + }) + + it('sets the USER_CREATED flag only once a code is verified', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + await verifyCode({ email: EMAIL, code: otp!.value }) + + const flag = await databaseConnection().getRepository('flag').findOneBy({ id: 'USER_CREATED' }) + expect(flag?.value).toBe(true) + }) + + it('rejects a wrong code and counts the attempt', async () => { + await requestCode(EMAIL) + const issued = await storedOtp(EMAIL) + + const response = await verifyCode({ email: EMAIL, code: wrongCodeFor(issued!.value) }) + + expect(response?.statusCode).toBe(StatusCodes.GONE) + expect((await storedOtp(EMAIL))?.attempts).toBe(1) + }) + + it('discards the credential after five wrong attempts', async () => { + await requestCode(EMAIL) + const otp = await storedOtp(EMAIL) + + for (let attempt = 0; attempt < 5; attempt++) { + await verifyCode({ email: EMAIL, code: wrongCodeFor(otp!.value) }) + } + + expect(await storedOtp(EMAIL)).toBeNull() + const response = await verifyCode({ email: EMAIL, code: otp!.value }) + expect(response?.statusCode).toBe(StatusCodes.GONE) + }) + + it('rejects an address that never requested a code', async () => { + const response = await verifyCode({ email: 'nobody@example.com', code: '123456' }) + + expect(response?.statusCode).toBe(StatusCodes.GONE) + }) + }) +}) diff --git a/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts b/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts new file mode 100644 index 000000000000..22d4c51e80bf --- /dev/null +++ b/packages/server/api/test/integration/ce/platform/first-platform-provisioning.test.ts @@ -0,0 +1,226 @@ +import { apId } from '@activepieces/core-utils' +import { PlatformRole, TelemetryEventName, UserStatus } from '@activepieces/shared' +import { FastifyBaseLogger, FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { authenticationUtils } from '../../../../src/app/authentication/authentication-utils' +import { databaseConnection } from '../../../../src/app/database/database-connection' +import { platformService } from '../../../../src/app/platform/platform.service' +import { createMockPlatform, createMockUserIdentity } from '../../../helpers/mocks' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +const trackProject = vi.fn() + +vi.mock('../../../../src/app/helper/telemetry.utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + telemetry: (log: FastifyBaseLogger) => ({ ...actual.telemetry(log), trackProject }), + } +}) + +let app: FastifyInstance | null = null + +const EMAIL = 'first.platform@example.com' + +async function seedVerifiedIdentity(): Promise { + const identity = createMockUserIdentity({ email: EMAIL, verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + return identity.id +} + +async function onboardingToken(identityId: string): Promise { + const response = await authenticationUtils(app!.log).getOnboardingResponse({ identityId }) + return response.token +} + +async function createViaRoute({ token, name }: { token: string, name: string }) { + return app?.inject({ + method: 'POST', + url: '/api/v1/platforms', + headers: { authorization: `Bearer ${token}` }, + body: { name }, + }) +} + +async function createFirstPlatform(identityId: string, callerTokenVersion?: string) { + const { response } = await platformService(app!.log).createPlatformWithProject({ + identityId, + name: 'Ahmad', + invalidatePreviousTokens: true, + isFirstPlatform: true, + callerTokenVersion, + }) + return response +} + +function provisionFirstPlatform(identityId: string) { + return platformService(app!.log).createPlatformWithProject({ + identityId, + name: 'Ahmad', + invalidatePreviousTokens: true, + isFirstPlatform: true, + callerTokenVersion: undefined, + }) +} + +async function tokenVersionOf(identityId: string): Promise { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ id: identityId }) + return identity!.tokenVersion +} + +async function strandUser(identityId: string): Promise { + const userId = apId() + await databaseConnection().getRepository('user').save({ + id: userId, + identityId, + platformId: null, + platformRole: PlatformRole.ADMIN, + status: UserStatus.ACTIVE, + }) + return userId +} + +beforeAll(async () => { + app = await setupTestEnvironment() +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +beforeEach(async () => { + trackProject.mockClear() + await databaseConnection().getRepository('project').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('platform').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user_identity').createQueryBuilder().delete().execute() +}) + +describe('First platform provisioning', () => { + it('gives one identity a single platform however many times it asks', async () => { + const identityId = await seedVerifiedIdentity() + + const first = await createFirstPlatform(identityId) + const second = await createFirstPlatform(identityId) + + expect(second.platformId).toBe(first.platformId) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + expect(await databaseConnection().getRepository('project').count()).toBe(1) + expect(await databaseConnection().getRepository('user').count()).toBe(1) + }) + + it('tells exactly one of two racing callers that it provisioned the platform', async () => { + const identityId = await seedVerifiedIdentity() + + const results = await Promise.all([ + provisionFirstPlatform(identityId), + provisionFirstPlatform(identityId), + ]) + + expect(results.filter((result) => result.provisioned)).toHaveLength(1) + }) + + it('reuses a user left unlinked by an interrupted attempt instead of creating a second one', async () => { + const identityId = await seedVerifiedIdentity() + await strandUser(identityId) + + await createFirstPlatform(identityId) + + expect(await databaseConnection().getRepository('user').count()).toBe(1) + }) + + it('adopts a platform whose owner link never landed instead of building a second one', async () => { + const identityId = await seedVerifiedIdentity() + const strandedUserId = await strandUser(identityId) + await databaseConnection().getRepository('platform').save( + createMockPlatform({ ownerId: strandedUserId }), + ) + + const response = await createFirstPlatform(identityId) + + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + expect(await databaseConnection().getRepository('user').count()).toBe(1) + const relinked = await databaseConnection().getRepository('user').findOneBy({ id: strandedUserId }) + expect(relinked?.platformId).toBe(response.platformId) + }) + + it('reports the signup it finished for a platform whose owner link never landed', async () => { + const identityId = await seedVerifiedIdentity() + const strandedUserId = await strandUser(identityId) + await databaseConnection().getRepository('platform').save( + createMockPlatform({ ownerId: strandedUserId }), + ) + + const response = await createFirstPlatform(identityId) + + const signedUp = trackProject.mock.calls.filter(([, event]) => event.name === TelemetryEventName.SIGNED_UP) + expect(signedUp).toHaveLength(1) + expect(signedUp[0][0]).toBe(response.projectId) + }) + + it('repairs a platform left without a project instead of wedging the identity', async () => { + const identityId = await seedVerifiedIdentity() + const first = await createFirstPlatform(identityId) + await databaseConnection().getRepository('project').createQueryBuilder().delete().execute() + + const retry = await createFirstPlatform(identityId) + + expect(retry.platformId).toBe(first.platformId) + expect(await databaseConnection().getRepository('project').count()).toBe(1) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + }) + + it('finishes the rotation an interrupted attempt never got to', async () => { + const identityId = await seedVerifiedIdentity() + const strandedUserId = await strandUser(identityId) + await databaseConnection().getRepository('platform').save( + createMockPlatform({ ownerId: strandedUserId }), + ) + await databaseConnection().getRepository('user') + .update(strandedUserId, { platformId: (await databaseConnection().getRepository('platform').findOneBy({ ownerId: strandedUserId }))!.id }) + const beforeRetry = await tokenVersionOf(identityId) + + await createFirstPlatform(identityId, beforeRetry) + + expect(await tokenVersionOf(identityId)).not.toBe(beforeRetry) + }) + + it('leaves the token version alone for a duplicate that carries a spent version', async () => { + const identityId = await seedVerifiedIdentity() + await createFirstPlatform(identityId, await tokenVersionOf(identityId)) + const afterFirst = await tokenVersionOf(identityId) + + await createFirstPlatform(identityId, 'a-version-from-before-the-rotation') + + expect(await tokenVersionOf(identityId)).toBe(afterFirst) + }) + + it('rotates once when two first-platform creations race, so neither session is stranded', async () => { + const identityId = await seedVerifiedIdentity() + + const [first, second] = await Promise.all([ + createFirstPlatform(identityId), + createFirstPlatform(identityId), + ]) + + const after = await databaseConnection().getRepository('user_identity').findOneBy({ id: identityId }) + const versionOf = (token: string) => + JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).tokenVersion + expect(versionOf(first.token)).toBe(after?.tokenVersion) + expect(versionOf(second.token)).toBe(after?.tokenVersion) + }) + + it('serves the onboarding route without provisioning a second platform', async () => { + const identityId = await seedVerifiedIdentity() + const token = await onboardingToken(identityId) + + const created = await createViaRoute({ token, name: 'Ahmad' }) + + expect(created?.statusCode).toBe(StatusCodes.OK) + expect(await databaseConnection().getRepository('platform').count()).toBe(1) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ id: identityId }) + expect(identity?.tokenVersion).not.toBe( + JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).tokenVersion, + ) + }) +}) diff --git a/packages/server/api/test/integration/cloud/authn/enterprise-local-authn.test.ts b/packages/server/api/test/integration/cloud/authn/enterprise-local-authn.test.ts index 0bb20ca0b828..3b853f5e3e80 100644 --- a/packages/server/api/test/integration/cloud/authn/enterprise-local-authn.test.ts +++ b/packages/server/api/test/integration/cloud/authn/enterprise-local-authn.test.ts @@ -52,7 +52,7 @@ describe('Enterprise Local Authn API', () => { const userIdentity = await db.findOneBy('user_identity', { id: mockUserIdentity.id }) expect(userIdentity?.verified).toBe(true) const otp = await db.findOneBy('otp', { id: mockOtp.id }) - expect(otp?.state).toBe(OtpState.CONFIRMED) + expect(otp).toBeNull() }) it('Fails if OTP is wrong', async () => { 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 056457da2e24..27f087685183 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 @@ -1,8 +1,11 @@ import { apId } from '@activepieces/core-utils' -import { AgentIcon, AgentVisibility, ColorName, DefaultProjectRole } from '@activepieces/shared' +import { AgentIcon, AgentVisibility, ColorName, DEFAULT_AGENT_MAX_STEPS, DefaultProjectRole, MAX_DRAFT_PROMPT_LENGTH } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' +import { db } from '../../../helpers/db' import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' +import { DRAFTS_PER_MINUTE } from '../../../../src/app/ee/agent/agent-controller' +import { AGENT_TEMPLATES } from '../../../../src/app/ee/agent/agent-templates' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' let app: FastifyInstance @@ -73,6 +76,228 @@ describe('agent crud', () => { }) }) +describe('agent publish', () => { + it('copies the draft to published, so a flow step has something to run', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + + const response = await ctx.post(`/v1/agents/${agent.id}/publish`) + + expect(response.statusCode).toBe(StatusCodes.OK) + expect(response.json().published).toStrictEqual(response.json().draft) + }) + + it('leaves the published copy alone when the draft moves on', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await ctx.post(`/v1/agents/${agent.id}/publish`) + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Rewritten.' } }) + + const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() + expect(after.draft.instructions).toBe('Rewritten.') + expect(after.published.instructions).toBe('Draft launch posts.') + }) + + it.each([['spaces', ' '], ['tabs', '\t\t'], ['newlines', '\n\n'], ['empty', '']])( + 'refuses to publish an agent whose instructions are only %s', + async (_kind, instructions) => { + const ctx = await context() + const agent = await createAgent(ctx, { draft: { ...agentBody(ctx.project.id).draft, instructions } }) + + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.CONFLICT) + expect((await ctx.get(`/v1/agents/${agent.id}`)).json().published).toBeNull() + }) + + it('keeps a rich config byte-for-byte through the copy', async () => { + const ctx = await context() + const draft = { + instructions: 'Check the brand guide first.', + provider: null, + modelName: 'claude-sonnet-4-6', + maxSteps: 7, + tools: [], + structuredOutput: [{ displayName: 'summary', type: 'text' }], + } + const agent = await createAgent(ctx, { draft }) + + const published = (await ctx.post(`/v1/agents/${agent.id}/publish`)).json().published + expect(published).toStrictEqual(draft) + }) + + it('republishes the newer draft, and is a no-op when nothing changed', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + const first = (await ctx.post(`/v1/agents/${agent.id}/publish`)).json() + const again = (await ctx.post(`/v1/agents/${agent.id}/publish`)).json() + expect(again.published).toStrictEqual(first.published) + + await ctx.post(`/v1/agents/${agent.id}`, { draft: { ...agentBody(ctx.project.id).draft, instructions: 'Second version.' } }) + const third = (await ctx.post(`/v1/agents/${agent.id}/publish`)).json() + expect(third.published.instructions).toBe('Second version.') + }) + + it('keeps the published config when the agent is edited afterwards', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await ctx.post(`/v1/agents/${agent.id}/publish`) + + await ctx.post(`/v1/agents/${agent.id}`, { displayName: 'Renamed' }) + + const after = (await ctx.get(`/v1/agents/${agent.id}`)).json() + expect(after.displayName).toBe('Renamed') + expect(after.published).not.toBeNull() + }) + + it('publishes a piece tool with its predefined input intact', async () => { + const ctx = await context() + const draft = { + instructions: 'File the ticket.', + provider: null, + modelName: null, + maxSteps: 3, + tools: [{ + type: 'PIECE', + toolName: 'create_issue', + pieceMetadata: { + pieceName: '@activepieces/piece-github', + pieceVersion: '0.1.0', + actionName: 'create_issue', + predefinedInput: { fields: { title: { mode: 'choose-yourself', value: 'Bug' } } }, + }, + }], + structuredOutput: [], + } + const agent = await createAgent(ctx, { draft }) + + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).json().published).toStrictEqual(draft) + }) + + it('refuses instructions that are only a non-breaking space', async () => { + const ctx = await context() + const agent = await createAgent(ctx, { draft: { ...agentBody(ctx.project.id).draft, instructions: '\u00a0' } }) + + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.CONFLICT) + }) + + it('refuses to publish a restricted agent the caller cannot see', async () => { + const owner = await context() + const member = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const agent = await createAgent(owner, { visibility: AgentVisibility.RESTRICTED }) + + expect((await member.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.NOT_FOUND) + expect((await owner.get(`/v1/agents/${agent.id}`)).json().published).toBeNull() + }) + + it('refuses a viewer, and an agent in another project', async () => { + const owner = await context() + const viewer = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.VIEWER }) + const stranger = await context() + const agent = await createAgent(owner) + + expect((await viewer.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await stranger.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await owner.get(`/v1/agents/${agent.id}`)).json().published).toBeNull() + }) +}) + +describe('agent governance', () => { + it('keeps a project admin able to see an agent an editor restricted', async () => { + const owner = await context() + const editor = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const agent = await createAgent(editor) + + await editor.post(`/v1/agents/${agent.id}`, { visibility: AgentVisibility.RESTRICTED }) + + expect((await owner.get(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.OK) + }) + + it('refuses to let a non-owner hide an agent from the rest of the project', async () => { + const owner = await context() + const editor = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const mine = await createAgent(owner) + + const response = await editor.post(`/v1/agents/${mine.id}`, { visibility: AgentVisibility.RESTRICTED }) + + expect(response.statusCode).toBe(StatusCodes.FORBIDDEN) + expect((await owner.get(`/v1/agents/${mine.id}`)).json().visibility).toBe(AgentVisibility.PROJECT) + }) + + it('lets an editor still rename an agent, so the gate is on sharing only', async () => { + const owner = await context() + const editor = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const agent = await createAgent(owner) + + expect((await editor.post(`/v1/agents/${agent.id}`, { displayName: 'Renamed' })).statusCode).toBe(StatusCodes.OK) + }) + + it('takes a published agent offline without destroying it', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + await ctx.post(`/v1/agents/${agent.id}/publish`) + + const response = await ctx.post(`/v1/agents/${agent.id}/unpublish`) + + expect(response.statusCode).toBe(StatusCodes.OK) + expect(response.json().published).toBeNull() + expect(response.json().draft.instructions).toBe('Draft launch posts.') + }) + + it('never returns a stored mcp credential to a reader', async () => { + const ctx = await context() + const draft = { + instructions: 'Use the server.', + tools: [{ + type: 'MCP', + toolName: 'remote', + serverUrl: 'https://mcp.example.com', + protocol: 'streamable-http', + auth: { type: 'api_key', apiKey: 'sk-live-SECRET', apiKeyHeader: 'x-api-key' }, + }], + } + const agent = await createAgent(ctx, { draft }) + + expect((await ctx.get(`/v1/agents/${agent.id}`)).body).not.toContain('sk-live-SECRET') + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).body).not.toContain('sk-live-SECRET') + expect((await ctx.get('/v1/agents')).body).not.toContain('sk-live-SECRET') + }) + + it('keeps the stored credential usable after an unrelated edit', async () => { + const ctx = await context() + const draft = { + instructions: 'Use the server.', + tools: [{ + type: 'MCP', + toolName: 'remote', + serverUrl: 'https://mcp.example.com', + protocol: 'streamable-http', + auth: { type: 'api_key', apiKey: 'sk-live-SECRET', apiKeyHeader: 'x-api-key' }, + }], + } + const agent = await createAgent(ctx, { draft }) + + await ctx.post(`/v1/agents/${agent.id}`, { displayName: 'Renamed' }) + + const stored = await db.findOneByOrFail<{ draft: { tools: { auth: { apiKey?: string } }[] } }>('agent', { id: agent.id }) + expect(stored.draft.tools[0].auth.apiKey).toBe('sk-live-SECRET') + }) + + it('refuses a config larger than an agent is allowed to be', async () => { + const ctx = await context() + const fields = Object.fromEntries(Array.from({ length: 4000 }, (_, index) => [`f${index}`, { mode: 'choose-yourself', value: 'x'.repeat(100) }])) + const draft = { + instructions: 'Big.', + tools: [{ + type: 'PIECE', + toolName: 'big', + pieceMetadata: { pieceName: 'p', pieceVersion: '1.0.0', actionName: 'a', predefinedInput: { fields } }, + }], + } + + expect((await ctx.post('/v1/agents', agentBody(ctx.project.id, { draft }))).statusCode).toBe(StatusCodes.BAD_REQUEST) + }) +}) + describe('agent project isolation', () => { it.each([ ['read', (ctx: TestContext, id: string) => ctx.get(`/v1/agents/${id}`)], @@ -166,10 +391,89 @@ describe('agent permissions', () => { }) }) +describe('agent templates', () => { + it('serves starter agents with no ai provider and no connections configured', async () => { + const ctx = await context() + + const response = await ctx.get('/v1/agents/templates') + + expect(response.statusCode).toBe(StatusCodes.OK) + const templates = response.json().data + expect(templates.length).toBe(AGENT_TEMPLATES.length) + expect(new Set(templates.map((t: { id: string }) => t.id)).size).toBe(templates.length) + for (const template of templates) { + expect(template.instructions.length).toBeGreaterThan(0) + } + }) + + it.each(AGENT_TEMPLATES.map((template) => [template.id, template]))( + 'creates and publishes the %s starter, with only what the template carries', + async (_id, template) => { + const ctx = await context() + + const created = await ctx.post('/v1/agents', { + projectId: ctx.project.id, + displayName: template.displayName, + description: template.description, + icon: template.icon, + color: template.color, + draft: { instructions: template.instructions }, + }) + + expect(created.statusCode).toBe(StatusCodes.CREATED) + expect(created.json().description).toBe(template.description) + expect(created.json().draft.maxSteps).toBe(DEFAULT_AGENT_MAX_STEPS) + expect((await ctx.post(`/v1/agents/${created.json().id}/publish`)).statusCode).toBe(StatusCodes.OK) + }) + + it('tells the caller to connect a provider, rather than naming an internal entity', async () => { + const ctx = await context() + + const drafted = await ctx.post('/v1/agents/draft', { projectId: ctx.project.id, prompt: 'watch competitor pricing' }) + + expect(drafted.statusCode).toBe(StatusCodes.CONFLICT) + expect(drafted.body).toContain('Connect an AI provider') + expect(drafted.body).not.toContain('ChatAiProvider') + }) + + it('rate limits one caller without blocking another on the same platform', async () => { + const owner = await context() + const colleague = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.EDITOR }) + const draft = (ctx: TestContext) => ctx.post('/v1/agents/draft', { projectId: owner.project.id, prompt: 'watch competitor pricing' }) + + const responses = [] + for (let attempt = 0; attempt <= DRAFTS_PER_MINUTE; attempt++) { + responses.push(await draft(owner)) + } + + expect(responses[responses.length - 1].body).toContain(`above the limit of ${DRAFTS_PER_MINUTE}`) + expect((await draft(colleague)).body).not.toContain('above the limit') + }) + + it('refuses a draft prompt longer than the endpoint is meant to take', async () => { + const ctx = await context() + + const response = await ctx.post('/v1/agents/draft', { projectId: ctx.project.id, prompt: 'a'.repeat(MAX_DRAFT_PROMPT_LENGTH + 1) }) + + expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST) + }) + + it('refuses to draft for a project the caller cannot write', async () => { + const owner = await context() + const viewer = await createMemberContext(app, owner, { projectRole: DefaultProjectRole.VIEWER }) + + const response = await viewer.post('/v1/agents/draft', { projectId: owner.project.id, prompt: 'anything' }) + + expect(response.statusCode).toBe(StatusCodes.FORBIDDEN) + }) + +}) + describe('agent routes coexist with the chat routes already on /v1/agents', () => { it('does not swallow the static sibling routes with /:id', async () => { const ctx = await createTestContext(app, { plan: { agentsEnabled: true, chatEnabled: true } }) + expect((await ctx.get('/v1/agents/templates')).statusCode).toBe(StatusCodes.OK) expect((await ctx.get('/v1/agents/memory')).statusCode).toBe(StatusCodes.OK) expect((await ctx.get('/v1/agents/conversations')).statusCode).toBe(StatusCodes.OK) }) @@ -182,10 +486,19 @@ describe('agent routes coexist with the chat routes already on /v1/agents', () = }) describe('agent feature gate', () => { - it('refuses every agent route when the platform does not have agents', async () => { - const ctx = await createTestContext(app, { plan: { agentsEnabled: false } }) + it('refuses every agent route once the platform loses the entitlement', async () => { + const ctx = await context() + const agent = await createAgent(ctx) + const plan = await db.findOneByOrFail<{ id: string }>('platform_plan', { platformId: ctx.platform.id }) + await db.update('platform_plan', plan.id, { agentsEnabled: false }) expect((await ctx.get('/v1/agents')).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) expect((await ctx.post('/v1/agents', agentBody(ctx.project.id))).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.get(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.post(`/v1/agents/${agent.id}`, { displayName: 'x' })).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.post(`/v1/agents/${agent.id}/publish`)).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.get('/v1/agents/templates')).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.post('/v1/agents/draft', { projectId: ctx.project.id, prompt: 'x' })).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) + expect((await ctx.delete(`/v1/agents/${agent.id}`)).statusCode).toBe(StatusCodes.PAYMENT_REQUIRED) }) }) diff --git a/packages/server/api/test/unit/app/authentication/disposable-email.test.ts b/packages/server/api/test/unit/app/authentication/disposable-email.test.ts new file mode 100644 index 000000000000..19f70fe26291 --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/disposable-email.test.ts @@ -0,0 +1,38 @@ +import { disposableEmail } from '../../../../src/app/authentication/lib/disposable-email' + +describe('disposableEmail', () => { + describe('isDisposable', () => { + it.each([ + 'someone@mailinator.com', + 'someone@guerrillamail.com', + 'someone@10minutemail.com', + ])('rejects the throwaway provider in %s', (email) => { + expect(disposableEmail.isDisposable(email)).toBe(true) + }) + + it.each([ + 'ahmad@activepieces.com', + 'someone@gmail.com', + 'someone@outlook.com', + 'someone@googlemail.com', + ])('accepts the real provider in %s', (email) => { + expect(disposableEmail.isDisposable(email)).toBe(false) + }) + + it('matches a subdomain of a wildcard provider', () => { + const wildcardHit = disposableEmail.isDisposable('someone@mail.mailinator.com') + const unrelated = disposableEmail.isDisposable('someone@mailinator.com.activepieces.com') + + expect(wildcardHit).toBe(true) + expect(unrelated).toBe(false) + }) + + it('ignores case and surrounding whitespace in the domain', () => { + expect(disposableEmail.isDisposable('Someone@MAILINATOR.com ')).toBe(true) + }) + + it('treats an address with no domain as acceptable, leaving that to schema validation', () => { + expect(disposableEmail.isDisposable('not-an-email')).toBe(false) + }) + }) +}) diff --git a/packages/server/api/test/unit/app/authentication/signup-names.test.ts b/packages/server/api/test/unit/app/authentication/signup-names.test.ts new file mode 100644 index 000000000000..c6ec3dcdbdc2 --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/signup-names.test.ts @@ -0,0 +1,91 @@ +import { signupNames } from '../../../../src/app/authentication/lib/signup-names' + +describe('signupNames', () => { + describe('firstNameFromEmail', () => { + it.each([ + ['ahmad@activepieces.com', 'Ahmad'], + ['ahmad.tash@activepieces.com', 'Ahmad'], + ['ahmad_tash@activepieces.com', 'Ahmad'], + ['ahmad+work@activepieces.com', 'Ahmad'], + ['AHMAD@activepieces.com', 'AHMAD'], + ])('derives %s into %s', (email, expected) => { + expect(signupNames.firstNameFromEmail(email)).toBe(expected) + }) + + it('falls back when the local part carries no letters or digits', () => { + expect(signupNames.firstNameFromEmail('...@activepieces.com')).toBe('there') + }) + }) + + describe('splitFullName', () => { + it.each([ + ['Ahmad Tash', 'Ahmad', 'Tash'], + ['Ahmad', 'Ahmad', ''], + [' Ahmad Tash ', 'Ahmad', 'Tash'], + ['Ahmad Bin Tash', 'Ahmad', 'Bin Tash'], + ['ahmad tash', 'ahmad', 'tash'], + ])('splits %s into %s / %s', (fullName, firstName, lastName) => { + expect( + signupNames.splitFullName({ fullName, email: 'someone@activepieces.com' }), + ).toEqual({ firstName, lastName }) + }) + + it('strips the characters the platform name rule rejects', () => { + expect( + signupNames.splitFullName({ fullName: 'J. Smith', email: 'j@activepieces.com' }), + ).toEqual({ firstName: 'J', lastName: 'Smith' }) + }) + + it('falls back to the email when the name carries nothing usable', () => { + expect( + signupNames.splitFullName({ fullName: ' ', email: 'ahmad@activepieces.com' }), + ).toEqual({ firstName: 'Ahmad', lastName: '' }) + }) + }) + + describe('platformNameFromPerson', () => { + it.each([ + ['Ahmad', "Ahmad's Platform"], + ['Ahmad Bin', "Ahmad's Platform"], + ['Chris', "Chris's Platform"], + ["Ahmad's", "Ahmad's Platform"], + ])('names the platform from %s -> %s', (firstName, expected) => { + expect( + signupNames.platformNameFromPerson({ firstName, email: 'a.b@activepieces.com' }), + ).toBe(expected) + }) + + it('falls back to the email local part when the person has no usable name', () => { + expect( + signupNames.platformNameFromPerson({ firstName: '', email: 'ahmad.tash@activepieces.com' }), + ).toBe("Ahmad's Platform") + }) + + it('uses the whole fallback when neither the name nor the address yields a word', () => { + expect( + signupNames.platformNameFromPerson({ firstName: '', email: '___@activepieces.com' }), + ).toBe('My Platform') + }) + + it('stays inside the platform name limit when the address is one long word', () => { + const name = signupNames.platformNameFromPerson({ + firstName: '', + email: `${'a'.repeat(120)}@activepieces.com`, + }) + + expect(name.length).toBeLessThanOrEqual(100) + }) + + it('never produces a name the platform name rule rejects', () => { + const safeString = new RegExp('^[^./]+$') + const name = signupNames.platformNameFromPerson({ + firstName: 'J./Smith', + email: 'j@activepieces.com', + }) + + expect(name).toMatch(safeString) + expect(name.length).toBeLessThanOrEqual(100) + }) + }) + +}) diff --git a/packages/server/api/test/unit/app/authentication/turnstile.test.ts b/packages/server/api/test/unit/app/authentication/turnstile.test.ts new file mode 100644 index 000000000000..69599d1ff094 --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/turnstile.test.ts @@ -0,0 +1,123 @@ +import { safeHttp } from '@activepieces/server-utils' +import { AxiosError, AxiosHeaders } from 'axios' +import { FastifyBaseLogger } from 'fastify' +import { turnstile } from '../../../../src/app/authentication/lib/turnstile' + +function siteVerifyStatus(status: number): AxiosError { + return new AxiosError('siteverify failed', 'ERR_BAD_REQUEST', undefined, undefined, { + status, + statusText: 'Bad Request', + data: {}, + headers: {}, + config: { headers: new AxiosHeaders() }, + }) +} + +const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn() } as unknown as FastifyBaseLogger + +function configure({ site, secret }: { site?: string, secret?: string }): void { + if (site === undefined) { + delete process.env.AP_TURNSTILE_SITE_KEY + } + else { + process.env.AP_TURNSTILE_SITE_KEY = site + } + if (secret === undefined) { + delete process.env.AP_TURNSTILE_SECRET_KEY + } + else { + process.env.AP_TURNSTILE_SECRET_KEY = secret + } +} + +beforeEach(() => { + vi.restoreAllMocks() + configure({}) +}) + +afterAll(() => { + configure({}) +}) + +describe('turnstile', () => { + describe('isConfigured', () => { + it('is off when neither key is set', () => { + expect(turnstile.isConfigured()).toBe(false) + }) + + it('stays off when only one key is set, so a half-configured instance serves no challenge', () => { + configure({ site: 'site-key' }) + expect(turnstile.isConfigured()).toBe(false) + expect(turnstile.siteKey()).toBeUndefined() + + configure({ secret: 'secret-key' }) + expect(turnstile.isConfigured()).toBe(false) + expect(turnstile.siteKey()).toBeUndefined() + }) + + it('treats a blank value as unset, so an empty env line cannot lock sign-up', () => { + configure({ site: ' ', secret: 'secret-key' }) + + expect(turnstile.isConfigured()).toBe(false) + expect(turnstile.siteKey()).toBeUndefined() + }) + + it('is on only when both keys carry a value', () => { + configure({ site: 'site-key', secret: 'secret-key' }) + + expect(turnstile.isConfigured()).toBe(true) + expect(turnstile.siteKey()).toBe('site-key') + }) + }) + + describe('assertSolved', () => { + it('asks nothing of the visitor when no challenge is configured', async () => { + const post = vi.spyOn(safeHttp.axios, 'post') + + await turnstile.assertSolved({ token: undefined, remoteIp: undefined, log }) + + expect(post).not.toHaveBeenCalled() + }) + + it('refuses a missing token once configured', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + + await expect(turnstile.assertSolved({ token: undefined, remoteIp: undefined, log })) + .rejects.toThrow() + }) + + it('refuses a token cloudflare rejects', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + vi.spyOn(safeHttp.axios, 'post').mockResolvedValue({ + data: { success: false, 'error-codes': ['invalid-input-response'] }, + }) + + await expect(turnstile.assertSolved({ token: 'spent', remoteIp: '1.2.3.4', log })) + .rejects.toThrow() + }) + + it('accepts a token cloudflare confirms', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + vi.spyOn(safeHttp.axios, 'post').mockResolvedValue({ data: { success: true } }) + + await expect(turnstile.assertSolved({ token: 'good', remoteIp: '1.2.3.4', log })) + .resolves.toBeUndefined() + }) + + it('lets the request through when cloudflare is unreachable, rather than taking sign-in down', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + vi.spyOn(safeHttp.axios, 'post').mockRejectedValue(new Error('ETIMEDOUT')) + + await expect(turnstile.assertSolved({ token: 'good', remoteIp: undefined, log })) + .resolves.toBeUndefined() + }) + + it('refuses when siteverify answers with an error status, which is an answer rather than an outage', async () => { + configure({ site: 'site-key', secret: 'secret-key' }) + vi.spyOn(safeHttp.axios, 'post').mockRejectedValue(siteVerifyStatus(400)) + + await expect(turnstile.assertSolved({ token: 'good', remoteIp: undefined, log })) + .rejects.toThrow() + }) + }) +}) diff --git a/packages/web/package.json b/packages/web/package.json index 4cfca0b474b7..f4de6f72377c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -3,6 +3,8 @@ "version": "0.0.1", "private": true, "dependencies": { + "@activepieces/core-formula": "workspace:*", + "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "@activepieces/shared": "workspace:*", "@codemirror/commands": "6.10.3", @@ -68,6 +70,7 @@ "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "3.0.5", "i18next-icu": "2.3.0", + "input-otp": "1.4.2", "jszip": "3.10.1", "jwt-decode": "4.0.0", "lucide-react": "0.576.0", @@ -111,9 +114,7 @@ "use-stick-to-bottom": "1.1.3", "vaul": "1.1.2", "zod": "4.3.6", - "zustand": "4.5.4", - "@activepieces/core-utils": "workspace:*", - "@activepieces/core-formula": "workspace:*" + "zustand": "4.5.4" }, "devDependencies": { "@tailwindcss/postcss": "4.1.17", diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index e1fef388ac5e..f9f5d64001ca 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2295,5 +2295,34 @@ "1 min ago": "1 min ago", "{count} mins ago": "{count} mins ago", "1 hour ago": "1 hour ago", - "{count} hours ago": "{count} hours ago" + "{count} hours ago": "{count} hours ago", + "Continue with Google": "Continue with Google", + "Check your inbox": "Check your inbox", + "Create your account": "Create your account", + "Dream big. Automate the rest.": "Dream big. Automate the rest.", + "Email sign-in is disabled": "Email sign-in is disabled", + "Enter your code": "Enter your code", + "Resend code": "Resend code", + "Resend code in {seconds}s": "Resend code in {seconds}s", + "Reset your password": "Reset your password", + "Send reset link": "Send reset link", + "Sign in or create your account": "Sign in or create your account", + "Sign in with password": "Sign in with password", + "Single sign-on": "Single sign-on", + "That code is invalid or expired. Try again.": "That code is invalid or expired. Try again.", + "That doesn’t look like an email address yet.": "That doesn’t look like an email address yet.", + "Use SSO": "Use SSO", + "Use password": "Use password", + "Use your work email for better personalization.": "Use your work email for better personalization.", + "We sent a 6-digit code to {email}": "We sent a 6-digit code to {email}", + "Welcome": "Welcome", + "You need an invitation to sign up.": "You need an invitation to sign up.", + "name@work.com": "name@work.com", + "Email verified": "Email verified", + "Full Name": "Full Name", + "Tell us your name so we know what to call you.": "Tell us your name so we know what to call you.", + "This names your workspace and how we greet you.": "This names your workspace and how we greet you.", + "What should we call you?": "What should we call you?", + "The verification step could not load. Disable your ad blocker for this page, then reload.": "The verification step could not load. Disable your ad blocker for this page, then reload.", + "That verification expired. Please try again.": "That verification expired. Please try again." } diff --git a/packages/web/src/api/authentication-api.ts b/packages/web/src/api/authentication-api.ts index 6c329173c96e..6a154591514b 100644 --- a/packages/web/src/api/authentication-api.ts +++ b/packages/web/src/api/authentication-api.ts @@ -2,6 +2,8 @@ import { ProjectRole } from '@activepieces/core-utils'; import { CreateOtpRequestBody, GetCurrentProjectMemberRoleQuery, + CompleteSignUpRequest, + RequestEmailCodeRequest, ResetPasswordRequestBody, VerifyEmailRequestBody, AuthenticationResponse, @@ -12,6 +14,7 @@ import { SwitchPlatformRequest, ThirdPartyAuthnProviderEnum, UserIdentity, + VerifyEmailCodeRequest, } from '@activepieces/shared'; import { api } from '@/lib/api'; @@ -43,6 +46,21 @@ export const authenticationApi = { request, ); }, + requestEmailCode(request: RequestEmailCodeRequest) { + return api.post('/v1/authentication/otp/request', request); + }, + completeSignUp(request: CompleteSignUpRequest) { + return api.post( + '/v1/authentication/complete-sign-up', + request, + ); + }, + verifyEmailCode(request: VerifyEmailCodeRequest) { + return api.post( + '/v1/authentication/otp/verify', + request, + ); + }, sendOtpEmail(request: CreateOtpRequestBody) { return api.post('/v1/otp', request); }, diff --git a/packages/web/src/app/routes/create-platform.tsx b/packages/web/src/app/routes/create-platform.tsx index 7deaad179ea5..e19095178955 100644 --- a/packages/web/src/app/routes/create-platform.tsx +++ b/packages/web/src/app/routes/create-platform.tsx @@ -1,129 +1,9 @@ -import { SAFE_STRING_PATTERN } from '@activepieces/core-utils'; -import { useMutation } from '@tanstack/react-query'; -import { HttpStatusCode } from 'axios'; -import { t } from 'i18next'; -import { useForm, SubmitHandler } from 'react-hook-form'; -import { Navigate } from 'react-router-dom'; +import { AuthLanding } from '@/features/authentication'; -import { platformApi } from '@/api/platforms-api'; -import { Button } from '@/components/ui/button'; -import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { AuthLayout } from '@/features/authentication/components/auth-form-template'; -import { api } from '@/lib/api'; -import { authenticationSession } from '@/lib/authentication-session'; -import { useRedirectAfterLogin } from '@/lib/navigation-utils'; - -type CreatePlatformSchema = { - name: string; +const CreatePlatformPage = () => { + return ; }; -function CreatePlatformForm() { - const redirectAfterLogin = useRedirectAfterLogin(); - const form = useForm({ - defaultValues: { - name: '', - }, - mode: 'onChange', - }); - - const { mutate, isPending } = useMutation({ - mutationFn: platformApi.createPlatform, - onSuccess: (data) => { - authenticationSession.saveResponse(data, false); - redirectAfterLogin(); - }, - onError: (error) => { - const isBadRequest = - api.isError(error) && - error.response?.status === HttpStatusCode.BadRequest; - form.setError('root.serverError', { - message: isBadRequest - ? t('Platform name cannot contain "." or "/"') - : t('Something went wrong, please try again later'), - }); - }, - }); - - const onSubmit: SubmitHandler = (data) => { - form.clearErrors('root.serverError'); - mutate({ name: data.name.trim() }); - }; - - return ( -
- - ( - - - - - - )} - /> - {form?.formState?.errors?.root?.serverError && ( - - {form.formState.errors.root.serverError.message} - - )} - - - - ); -} - -function CreatePlatformPage() { - const token = authenticationSession.getToken(); - - if (!token) { - return ; - } - - if (!authenticationSession.isOnboarding()) { - return ; - } - - return ( - -
-

- {t('Create your platform')} -

-

- {t('Give your platform a name to get started.')} -

-
- -
- ); -} +CreatePlatformPage.displayName = 'CreatePlatformPage'; export { CreatePlatformPage }; diff --git a/packages/web/src/app/routes/platform/infra/event-destinations/lib/use-event-labels.ts b/packages/web/src/app/routes/platform/infra/event-destinations/lib/use-event-labels.ts index ed278b4b4478..a00bd5767490 100644 --- a/packages/web/src/app/routes/platform/infra/event-destinations/lib/use-event-labels.ts +++ b/packages/web/src/app/routes/platform/infra/event-destinations/lib/use-event-labels.ts @@ -3,6 +3,19 @@ import { t } from 'i18next'; export const useEventLabels = (): EventLabelsMap => { return { + [ApplicationEventName.AGENT_CREATED]: { label: t('Agent created') }, + [ApplicationEventName.AGENT_UPDATED]: { label: t('Agent updated') }, + [ApplicationEventName.AGENT_DELETED]: { label: t('Agent deleted') }, + [ApplicationEventName.AGENT_PUBLISHED]: { + label: t('Agent published'), + description: t( + 'Fires when someone publishes an agent. Flow steps read the published copy, not the draft.', + ), + }, + [ApplicationEventName.AGENT_UNPUBLISHED]: { + label: t('Agent taken offline'), + description: t('Fires when someone takes an agent offline.'), + }, [ApplicationEventName.FLOW_RUN_STARTED]: { label: t('Flow run started') }, [ApplicationEventName.FLOW_RUN_FINISHED]: { label: t('Flow run finished'), diff --git a/packages/web/src/app/routes/platform/security/audit-logs/index.tsx b/packages/web/src/app/routes/platform/security/audit-logs/index.tsx index b3cca6555518..6ad2c0537247 100644 --- a/packages/web/src/app/routes/platform/security/audit-logs/index.tsx +++ b/packages/web/src/app/routes/platform/security/audit-logs/index.tsx @@ -490,6 +490,22 @@ function extractEventDetails(event: ApplicationEvent): EventDetailRow[] { const { variable } = event.data; return [{ label: t('Variable'), value: variable.name }]; } + case ApplicationEventName.AGENT_CREATED: + case ApplicationEventName.AGENT_UPDATED: + case ApplicationEventName.AGENT_DELETED: + case ApplicationEventName.AGENT_PUBLISHED: + case ApplicationEventName.AGENT_UNPUBLISHED: { + const { agent } = event.data; + return [ + { label: t('Agent'), value: agent.displayName }, + ...(agent.publishedDigest + ? [{ label: t('Published version'), value: agent.publishedDigest }] + : []), + ...(agent.publishedToolNames?.length + ? [{ label: t('Tools'), value: agent.publishedToolNames.join(', ') }] + : []), + ]; + } case ApplicationEventName.FOLDER_CREATED: case ApplicationEventName.FOLDER_UPDATED: case ApplicationEventName.FOLDER_DELETED: diff --git a/packages/web/src/app/routes/sign-in/index.tsx b/packages/web/src/app/routes/sign-in/index.tsx index 51de1e1192b5..314d9a6b54e8 100644 --- a/packages/web/src/app/routes/sign-in/index.tsx +++ b/packages/web/src/app/routes/sign-in/index.tsx @@ -1,7 +1,7 @@ -import { AuthFormTemplate } from '@/features/authentication'; +import { AuthLanding } from '@/features/authentication'; const SignInPage: React.FC = () => { - return ; + return ; }; SignInPage.displayName = 'SignInPage'; diff --git a/packages/web/src/app/routes/sign-up/index.tsx b/packages/web/src/app/routes/sign-up/index.tsx index 5626a11e8343..9dfc2e9f947c 100644 --- a/packages/web/src/app/routes/sign-up/index.tsx +++ b/packages/web/src/app/routes/sign-up/index.tsx @@ -1,7 +1,8 @@ -import { AuthFormTemplate } from '@/features/authentication'; +import { Navigate, useLocation } from 'react-router-dom'; const SignUpPage: React.FC = () => { - return ; + const location = useLocation(); + return ; }; SignUpPage.displayName = 'SignUpPage'; diff --git a/packages/web/src/components/custom/full-logo.tsx b/packages/web/src/components/custom/full-logo.tsx index 0e4a5d98a5c0..7567a811a30f 100644 --- a/packages/web/src/components/custom/full-logo.tsx +++ b/packages/web/src/components/custom/full-logo.tsx @@ -1,12 +1,13 @@ import { t } from 'i18next'; import { flagsHooks } from '@/hooks/flags-hooks'; +import { cn } from '@/lib/utils'; -const FullLogo = () => { +const FullLogo = ({ className }: { className?: string }) => { const branding = flagsHooks.useWebsiteBranding(); return ( -
+
, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)); +InputOTP.displayName = 'InputOTP'; + +const InputOTPGroup = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> +>(({ className, ...props }, ref) => ( +
+)); +InputOTPGroup.displayName = 'InputOTPGroup'; + +const InputOTPSlot = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext); + const slot = inputOTPContext.slots[index]; + const char = slot?.char; + const hasFakeCaret = slot?.hasFakeCaret; + const isActive = slot?.isActive; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +}); +InputOTPSlot.displayName = 'InputOTPSlot'; + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> +>(({ ...props }, ref) => ( +
+ +
+)); +InputOTPSeparator.displayName = 'InputOTPSeparator'; + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/packages/web/src/features/authentication/components/auth-form-template.tsx b/packages/web/src/features/authentication/components/auth-form-template.tsx index 7f3341a8d8eb..ecfc4b7a7aaa 100644 --- a/packages/web/src/features/authentication/components/auth-form-template.tsx +++ b/packages/web/src/features/authentication/components/auth-form-template.tsx @@ -1,53 +1,15 @@ -import { - ApEdition, - ApFlagId, - ThirdPartyAuthnProvidersToShowMap, -} from '@activepieces/shared'; +import { ApEdition, ApFlagId } from '@activepieces/shared'; import { t } from 'i18next'; import React, { useCallback, useEffect, useState } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { useTheme } from '@/components/providers/theme-provider'; -import { authenticationSession } from '@/lib/authentication-session'; -import { useRedirectAfterLogin } from '@/lib/navigation-utils'; import { cn } from '@/lib/utils'; import { FullLogo } from '../../../components/custom/full-logo'; -import { HorizontalSeparatorWithText } from '../../../components/ui/separator'; import { flagsHooks } from '../../../hooks/flags-hooks'; import { AuthAnimation } from './auth-animation'; -import { SamlLoginForm } from './saml-login-form'; -import { SignInForm } from './sign-in-form'; -import { SignUpForm } from './sign-up-form'; -import { ThirdPartyLogin } from './third-party-logins'; - -const BottomNote = ({ isSignup }: { isSignup: boolean }) => { - const [searchParams] = useSearchParams(); - const searchQuery = searchParams.toString(); - - return isSignup ? ( -
- {t('Already have an account?')} - - {t('Sign in')} - -
- ) : ( -
- {t("Don't have an account?")} - - {t('Sign up')} - -
- ); -}; const TermsFooter = () => { const { data: termsOfServiceUrl } = flagsHooks.useFlag( @@ -92,27 +54,6 @@ const TermsFooter = () => { ); }; -const AuthSeparator = ({ - isEmailAuthEnabled, -}: { - isEmailAuthEnabled: boolean; -}) => { - const { data: thirdPartyAuthProviders } = - flagsHooks.useFlag( - ApFlagId.THIRD_PARTY_AUTH_PROVIDERS_TO_SHOW_MAP, - ); - const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); - const isCloud = edition === ApEdition.CLOUD; - const hasThirdPartyLogin = - thirdPartyAuthProviders?.google || thirdPartyAuthProviders?.saml || isCloud; - - return hasThirdPartyLogin && isEmailAuthEnabled ? ( - - {t('or')} - - ) : null; -}; - const AuthImage = () => { const [loaded, setLoaded] = useState(false); const onLoad = useCallback(() => setLoaded(true), []); @@ -171,89 +112,4 @@ const AuthLayout = ({ AuthLayout.displayName = 'AuthLayout'; -const AuthFormTemplate = React.memo( - ({ form }: { form: 'signin' | 'signup' }) => { - const isSignUp = form === 'signup'; - const token = authenticationSession.getToken(); - const redirectAfterLogin = useRedirectAfterLogin(); - const [showCheckYourEmailNote, setShowCheckYourEmailNote] = useState(false); - const [showSamlLogin, setShowSamlLogin] = useState(false); - const { data: isEmailAuthEnabled } = flagsHooks.useFlag( - ApFlagId.EMAIL_AUTH_ENABLED, - ); - const data = { - signin: { - title: t('Welcome back'), - description: t('Sign in to pick up where you left off.'), - }, - signup: { - title: t('Create a new account'), - description: t('Join thousands of teams running on autopilot.'), - }, - }[form]; - - useEffect(() => { - if (token) { - redirectAfterLogin(); - } - }, [token, redirectAfterLogin]); - - if (token) { - return null; - } - - if (showSamlLogin) { - return ( - -
-

- {t('Sign in with SAML')} -

-
- setShowSamlLogin(false)} /> -
- ); - } - - return ( - - {!showCheckYourEmailNote && ( -
-

- {data.title} -

-
- )} - - {!showCheckYourEmailNote && ( - setShowSamlLogin(true)} - /> - )} - - - {isEmailAuthEnabled ? ( - isSignUp ? ( - - ) : ( - - ) - ) : null} - - -
- ); - }, -); - -AuthFormTemplate.displayName = 'AuthFormTemplate'; - -export { AuthFormTemplate, AuthLayout }; +export { AuthLayout }; diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-backdrop.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-backdrop.tsx new file mode 100644 index 000000000000..1d2522646c73 --- /dev/null +++ b/packages/web/src/features/authentication/components/auth-landing/auth-backdrop.tsx @@ -0,0 +1,230 @@ +import { + ArrowUp, + BarChart3, + Check, + ChevronsUpDown, + House, + MessageCircle, + Mic, + Paperclip, + Plus, + Search, + Sparkles, + Table2, + Workflow, +} from 'lucide-react'; + +import { flagsHooks } from '@/hooks/flags-hooks'; + +export function AuthBackdrop() { + const branding = flagsHooks.useWebsiteBranding(); + const logoUrl = branding.logos.logoIconUrl; + + return ( +
+ +
+
+
+ + + Daily Stripe summary + +
+
+
+ {CONVERSATION.map((turn, index) => + turn.role === 'user' ? ( + + ) : ( + + ), + )} +
+
+
+
+ +
+
+
+
+
+ ); +} + +function SidebarFacsimile({ logoUrl }: { logoUrl: string }) { + return ( +
+
+ + + Acme Inc + + +
+ +
+ + New chat +
+ +
+ {NAV_ITEMS.map(({ icon: Icon, label, active }) => ( +
+ + {label} +
+ ))} +
+ +
+ + Recent + + {RECENT_CHATS.map((title, index) => ( +
+ {title} +
+ ))} +
+ +
+
+
+
+
+ ); +} + +function UserTurn({ text }: { text: string }) { + return ( +
+
+ {text} +
+
+ ); +} + +function AssistantTurn({ turn }: { turn: AssistantTurnData }) { + return ( +
+ {turn.activity && ( + + + {turn.activity} + + )} +

+ {turn.text} +

+ {turn.steps && ( +
+ {turn.steps.map((step) => ( +
+ + {step} +
+ ))} +
+ )} +
+ ); +} + +function ComposerFacsimile() { + return ( +
+

+ Tell me what you need... (@ to mention, : for emoji) +

+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ ); +} + +const NAV_ITEMS = [ + { icon: House, label: 'Home', active: false }, + { icon: MessageCircle, label: 'Chats', active: true }, + { icon: Workflow, label: 'Automations', active: false }, + { icon: Table2, label: 'Tables', active: false }, + { icon: BarChart3, label: 'Insights', active: false }, + { icon: Search, label: 'Search', active: false }, +]; + +const RECENT_CHATS = [ + 'Daily Stripe summary', + 'Chase overdue invoices', + 'Onboard new signups', + 'Weekly report to leadership', + 'Sync HubSpot to Sheets', + 'Tidy up my inbox', +]; + +const CONVERSATION: Turn[] = [ + { + role: 'user', + text: "Every morning, pull yesterday's Stripe payments into a Google Sheet and post a summary in Slack.", + }, + { + role: 'assistant', + activity: 'Checked Stripe, Google Sheets and Slack', + text: 'Done. It runs at 8:00 every morning, writes one row per payment, and posts the daily total to #finance.', + steps: [ + 'Every day at 08:00', + 'Stripe: list yesterday’s payments', + 'Google Sheets: append rows', + 'Slack: send summary to #finance', + ], + }, + { + role: 'user', + text: 'Nice. Also ping me if a payment fails.', + }, + { + role: 'assistant', + text: 'Added a branch: failed payments now send you a direct message the moment Stripe reports them.', + }, +]; + +type AssistantTurnData = { + role: 'assistant'; + text: string; + activity?: string; + steps?: string[]; +}; + +type Turn = AssistantTurnData | { role: 'user'; text: string }; 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 new file mode 100644 index 000000000000..ab51a3237832 --- /dev/null +++ b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx @@ -0,0 +1,1107 @@ +import { ErrorCode, isNil } from '@activepieces/core-utils'; +import { + ApFlagId, + CreateOtpRequestBody, + MAX_FULL_NAME_LENGTH, + OtpType, + TelemetryEventName, +} from '@activepieces/shared'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { HttpStatusCode } from 'axios'; +import { t } from 'i18next'; +import { + ArrowLeft, + ArrowRight, + CircleAlert, + Lightbulb, + Mail, + User, +} from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { useSearchParams } from 'react-router-dom'; +import { z } from 'zod'; + +import { authenticationApi } from '@/api/authentication-api'; +import { FullLogo } from '@/components/custom/full-logo'; +import { useTelemetry } from '@/components/providers/telemetry-provider'; +import { Button } from '@/components/ui/button'; +import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { HorizontalSeparatorWithText } from '@/components/ui/separator'; +import { authMutations } from '@/features/authentication/hooks/auth-hooks'; +import { captchaUtils } from '@/features/authentication/utils/captcha-utils'; +import { flagsHooks } from '@/hooks/flags-hooks'; +import { HttpError, api } from '@/lib/api'; +import { authenticationSession } from '@/lib/authentication-session'; +import { formatUtils } from '@/lib/format-utils'; +import { useRedirectAfterLogin } from '@/lib/navigation-utils'; +import { cn } from '@/lib/utils'; + +import { CheckEmailNote } from '../check-email-note'; +import { SamlLoginForm } from '../saml-login-form'; +import { SignInForm } from '../sign-in-form'; +import { SignUpForm } from '../sign-up-form'; +import { + ThirdPartyLogin, + useShowThirdPartyProviders, + useThirdPartyAvailability, +} from '../third-party-logins'; + +import { TurnstileWidget, useTurnstileSiteKey } from './turnstile-widget'; + +const CODE_LENGTH = 6; + +const RESEND_COOLDOWN_SECONDS = 60; + +// Every title in the card shares this: Inter at 400 rather than the Sentient +// display serif — on a signup card a headline should read as a calm, modern +// label, not a statement that slows the eye down. 400 is the lightest weight +// actually loaded; 300 would silently fall back and look identical. +const AUTH_TITLE_CLASS = + 'text-center text-[21px] font-normal leading-snug tracking-[-0.02em] text-balance text-foreground'; + +// Steps cross-fade instead of snapping, and the card animates to the new +// height, so moving between email → code → password reads as one surface +// changing rather than three separate screens. +export function AuthDrawerBody({ initialMode }: AuthDrawerBodyProps) { + // All of it lives here, not in AuthStep: the animation keys AuthStep by + // step, so AuthStep remounts on every transition and would drop the email + // captured on the way to the code screen. + // A stored onboarding token means the member verified their email but never + // gave us a name, so resume there wherever they re-enter the app. + const [step, setStep] = useState( + authenticationSession.isOnboarding() ? 'name' : 'method', + ); + const [samlOpen, setSamlOpen] = useState(false); + // An invitation arrives as /sign-up?email=…, which that route forwards here + // with the search intact. The address is the invitee's, and they have no + // account yet, so the card opens on sign-up with the field already filled. + const invitedEmail = useSearchParams()[0].get('email') ?? ''; + const [mode, setMode] = useState( + invitedEmail.length > 0 ? 'signup' : initialMode, + ); + const [emailForCode, setEmailForCode] = useState(''); + const [checkEmailNote, setCheckEmailNote] = useState(false); + const { capture } = useTelemetry(); + const [captchaToken, setCaptchaToken] = useState(); + const [captchaReset, setCaptchaReset] = useState(0); + const [captchaUnavailable, setCaptchaUnavailable] = useState(false); + const handleCaptchaUnavailable = useCallback(() => { + setCaptchaUnavailable(true); + capture({ + name: TelemetryEventName.CAPTCHA_UNAVAILABLE, + payload: { surface: 'code-request' }, + }); + }, [capture]); + // A token is single-use, so every request spends the one in hand and the + // widget has to mint the next. The widget itself lives out here rather than + // inside a step: asking for a code and resending one are two requests, and a + // widget that unmounted with the email step would leave the resend with + // nothing to send. + const spendCaptcha = useCallback(() => { + setCaptchaToken(undefined); + setCaptchaReset((count) => count + 1); + }, []); + const captchaRequired = !isNil(useTurnstileSiteKey()) && !captchaUnavailable; + const challengeApplies = step === 'method' || step === 'code'; + + return ( + + + + + + + {challengeApplies && ( + + )} + + ); +} + +// Animates the card to each step's height off a measured value. Letting the +// height fall out of the layout instead (or animating to 'auto') collapses the +// card for a frame while the old step unmounts — that is the flicker. +function AutoHeight({ children }: { children: React.ReactNode }) { + const contentRef = useRef(null); + const [height, setHeight] = useState('auto'); + + useLayoutEffect(() => { + const element = contentRef.current; + if (!element) { + return; + } + const observer = new ResizeObserver(() => setHeight(element.offsetHeight)); + observer.observe(element); + setHeight(element.offsetHeight); + return () => observer.disconnect(); + }, []); + + return ( + +
{children}
+
+ ); +} + +function AuthStep({ + step, + setStep, + samlOpen, + setSamlOpen, + mode, + setMode, + emailForCode, + setEmailForCode, + checkEmailNote, + setCheckEmailNote, + invitedEmail, + captchaToken, + captchaRequired, + onCaptchaSpent, +}: AuthStepProps) { + const { data: emailAuthEnabledFlag } = flagsHooks.useFlag( + ApFlagId.EMAIL_AUTH_ENABLED, + ); + const { data: smtpConfigured } = flagsHooks.useFlag( + ApFlagId.SMTP_CONFIGURED, + ); + const { data: userCreated } = flagsHooks.useFlag( + ApFlagId.USER_CREATED, + ); + // Absent, not false: the flag has no row until the first account exists, so a + // fresh install omits it from /v1/flags entirely. Flags are loaded through a + // suspense query, so undefined here means missing rather than still loading. + const firstUser = userCreated !== true; + const effectiveMode: AuthMode = firstUser ? 'signup' : mode; + const emailAuthEnabled = emailAuthEnabledFlag ?? true; + const passwordlessAvailable = emailAuthEnabled && !!smtpConfigured; + const showThirdParty = useShowThirdPartyProviders(); + const thirdParty = useThirdPartyAvailability(); + + // The confirmation is a beat, not a screen: hold it just long enough to read + // as "that worked" before the name question replaces it. + useEffect(() => { + if (step !== 'verified') { + return; + } + const timer = setTimeout(() => setStep('name'), VERIFIED_HOLD_MS); + return () => clearTimeout(timer); + }, [step, setStep]); + + const abandonOnboarding = useCallback(() => { + authenticationSession.clearSession(); + setStep('method'); + }, [setStep]); + + if (samlOpen) { + return ( + + setSamlOpen(false)} /> + + setSamlOpen(false)} + showBackButton={false} + /> + + ); + } + + if (step === 'verified') { + return ( + + + + ); + } + + if (step === 'name') { + return ( + + + + + ); + } + + // No email/password auth at all — third-party only. + if (!emailAuthEnabled) { + return ( + + + setSamlOpen(true)} + /> + + ); + } + + // No email delivery configured (e.g. bare self-hosted): passwords stay the + // primary path, with the classic sign-in / sign-up forms. + if (!passwordlessAvailable) { + return ( + + + {showThirdParty && ( + <> + setSamlOpen(true)} + /> + + {t('or')} + + + )} + {effectiveMode === 'signup' ? ( + + ) : ( + + )} + {!firstUser && } + + ); + } + + if (step === 'password') { + return ( + + setStep('method')} /> + + {effectiveMode === 'signup' ? ( + + ) : ( + setStep('reset')} /> + )} + + ); + } + + // Resetting stays inside the card — leaving for /forget-password would drop + // the whole landing experience for one form. The shared ResetPasswordForm is + // a standalone page card (own chrome, own width, navigates away), so this + // step drives the same endpoint in the card's own language. + if (step === 'reset') { + return ( + + setStep('password')} /> + + + ); + } + + if (step === 'code') { + return ( + + setStep('method')} + onNeedsName={() => setStep('verified')} + /> + + ); + } + + return ( + +

+ {t('Dream big. Automate the rest.')} +

+ {/* Google leads: it is one tap against typing an address and waiting for + a code. SAML is enterprise plumbing — it lives with the quiet links + below so it never competes with the primary path. */} + {thirdParty.google && ( + <> + setSamlOpen(true)} + hideSaml + /> + + {t('or')} + + + )} + { + setEmailForCode(email); + setStep('code'); + }} + /> +
+ + {thirdParty.saml && ( + <> + + • + + + + )} +
+ +
+ ); +} + +function LegalNote() { + const { data: termsUrl } = flagsHooks.useFlag( + ApFlagId.TERMS_OF_SERVICE_URL, + ); + const { data: privacyUrl } = flagsHooks.useFlag( + ApFlagId.PRIVACY_POLICY_URL, + ); + + if (isNil(termsUrl) && isNil(privacyUrl)) { + return null; + } + + return ( +

+ {t('By continuing, you agree to our')}{' '} + {!isNil(termsUrl) && ( + + {t('Terms of Service')} + + )} + {!isNil(termsUrl) && !isNil(privacyUrl) && ` ${t('and')} `} + {!isNil(privacyUrl) && ( + + {t('Privacy Policy')} + + )} +

+ ); +} + +// A gentle nudge, never a blocker: personal addresses still work. It sits +// inside the field's own container, under a hairline. A lightbulb, not an +// alert — anything that reads as an error here costs signups. +function WorkEmailHint() { + return ( +
+ +

{t('Use your work email for better personalization.')}

+
+ ); +} + +function EmailStep({ + invitedEmail, + captchaToken, + captchaRequired, + onCaptchaSpent, + onCodeSent, +}: EmailStepProps) { + const form = useForm({ + resolver: zodResolver(EmailZodSchema), + defaultValues: { email: invitedEmail }, + // Never call an address invalid before the user has actually tried to + // continue — not while typing, not on blur. After a failed submit it + // corrects live as they fix it. + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + + // Only nudge once the address is actually complete, so the hint doesn't + // flicker while someone is still typing their domain. + const email = form.watch('email'); + const emailError = !!form.formState.errors.email; + const showWorkEmailHint = + formatUtils.emailRegex.test(email.trim()) && isPersonalEmail(email); + + const { mutate, isPending } = authMutations.useRequestEmailCode({ + onSuccess: () => { + onCaptchaSpent(); + onCodeSent(form.getValues().email.trim()); + }, + onError: (error) => { + onCaptchaSpent(); + form.setError('root.serverError', { + message: requestErrorMessage(error), + }); + }, + }); + + const onSubmit: SubmitHandler = (data) => { + form.clearErrors('root.serverError'); + mutate({ email: data.email.trim(), captchaToken }); + }; + + return ( +
+ + ( + + {/* One field, one affordance: the submit arrow lives inside the + input. When the address is personal the container grows a + note beneath the field — the field and the nudge read as one + object rather than a warning bolted underneath. */} +
+
+ + + +
+ {emailError ? ( +
+ +

{t('That doesn’t look like an email address yet.')}

+
+ ) : ( + showWorkEmailHint && + )} +
+
+ )} + /> + {form?.formState?.errors?.root?.serverError && ( + + {form.formState.errors.root.serverError.message} + + )} + + + ); +} + +function ResetStep() { + const [sentTo, setSentTo] = useState(null); + const form = useForm({ + resolver: zodResolver(EmailZodSchema), + defaultValues: { email: '' }, + mode: 'onChange', + }); + + const { mutate, isPending } = useMutation< + void, + HttpError, + CreateOtpRequestBody + >({ + mutationFn: authenticationApi.sendOtpEmail, + onSuccess: () => setSentTo(form.getValues().email.trim().toLowerCase()), + }); + + if (sentTo) { + return ( + <> + + + + ); + } + + return ( + <> + +
+ + mutate({ + email: data.email.trim().toLowerCase(), + type: OtpType.PASSWORD_RESET, + }), + )} + > + ( + +
+ + +
+ +
+ )} + /> + + + + + ); +} + +function VerifiedFlash() { + return ( +
+ + + + + + + {t('Email verified')} + +
+ ); +} + +function NameStep({ onSessionRejected }: NameStepProps) { + const redirectAfterLogin = useRedirectAfterLogin(); + const form = useForm({ + resolver: zodResolver(FullNameZodSchema), + defaultValues: { fullName: '' }, + mode: 'onSubmit', + reValidateMode: 'onChange', + }); + + const { mutate, isPending } = authMutations.useCompleteSignUp({ + onSuccess: (data) => { + authenticationSession.saveResponse(data, false); + redirectAfterLogin(); + }, + onError: (error) => { + if ( + api.isError(error) && + error.response?.status === HttpStatusCode.Unauthorized + ) { + onSessionRejected(); + return; + } + form.setError('root.serverError', { + message: t('Something went wrong, please try again later'), + }); + }, + }); + + const onSubmit: SubmitHandler = (data) => { + form.clearErrors('root.serverError'); + mutate({ fullName: data.fullName.trim() }); + }; + + return ( +
+ + ( + +
+
+ + +
+ {form.formState.errors.fullName && ( +
+ +

{t('Tell us your name so we know what to call you.')}

+
+ )} +
+
+ )} + /> + {form?.formState?.errors?.root?.serverError && ( + + {form.formState.errors.root.serverError.message} + + )} + + + + ); +} + +function CodeStep({ + captchaToken, + captchaRequired, + onCaptchaSpent, + email, + onBack, + onNeedsName, +}: CodeStepProps) { + const [code, setCode] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS); + const redirectAfterLogin = useRedirectAfterLogin(); + const { capture } = useTelemetry(); + + useEffect(() => { + if (cooldown <= 0) { + return; + } + const timer = setTimeout(() => setCooldown((value) => value - 1), 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + const { mutate: verify, isPending: isVerifying } = + 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)) { + onNeedsName(); + return; + } + redirectAfterLogin(); + }, + onError: (error) => { + setCode(''); + setErrorMessage(codeErrorMessage(error)); + capture({ + name: TelemetryEventName.EMAIL_CODE_REJECTED, + payload: { errorCode: serverErrorCode(error) ?? 'UNKNOWN' }, + }); + }, + }); + + const { mutate: resend, isPending: isResending } = + authMutations.useRequestEmailCode({ + onSuccess: () => { + onCaptchaSpent(); + setCooldown(RESEND_COOLDOWN_SECONDS); + capture({ + name: TelemetryEventName.EMAIL_CODE_RESEND_REQUESTED, + payload: {}, + }); + }, + onError: (error) => { + onCaptchaSpent(); + setErrorMessage(requestErrorMessage(error)); + }, + }); + + const handleChange = (value: string) => { + setErrorMessage(null); + setCode(value); + if (value.length === CODE_LENGTH) { + verify({ email, code: value }); + } + }; + + return ( + <> + + +
+ + + {Array.from({ length: CODE_LENGTH }).map((_, index) => ( + + ))} + + + {errorMessage && ( +

{errorMessage}

+ )} + +
+ + ); +} + +function DrawerShell({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+ {children} +
+ ); +} + +function Heading({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+

{title}

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ ); +} + +function BackLink({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +function ModeSwitch({ + mode, + onSwitch, +}: { + mode: AuthMode; + onSwitch: (mode: AuthMode) => void; +}) { + return ( +
+ {mode === 'signup' + ? t('Already have an account?') + : t("Don't have an account?")} + +
+ ); +} + +// Country variants are endless (yahoo.co.uk, hotmail.fr, …), so match the +// provider by prefix and keep the exact list for the one-off domains. +function isPersonalEmail(email: string): boolean { + const domain = email.trim().toLowerCase().split('@')[1]; + if (!domain) { + return false; + } + return ( + PERSONAL_EMAIL_DOMAINS.has(domain) || + PERSONAL_EMAIL_PREFIXES.some((prefix) => domain.startsWith(prefix)) + ); +} + +function requestErrorMessage(error: HttpError): string { + if (api.isError(error)) { + const errorCode = (error.response?.data as { code?: ErrorCode })?.code; + if (errorCode === ErrorCode.INVITATION_ONLY_SIGN_UP) { + return t('You need an invitation to sign up.'); + } + if (errorCode === ErrorCode.DOMAIN_NOT_ALLOWED) { + return t('Email domain is disallowed'); + } + if (errorCode === ErrorCode.EMAIL_AUTH_DISABLED) { + return t('Email sign-in is disabled'); + } + if (captchaUtils.isRejection(error)) { + return t('That verification expired. Please try again.'); + } + } + return t('Something went wrong, please try again later'); +} + +function serverErrorCode(error: HttpError): string | undefined { + return (error.response?.data as { code?: string })?.code; +} + +function codeErrorMessage(error: HttpError): string { + if (api.isError(error)) { + const errorCode = (error.response?.data as { code?: ErrorCode })?.code; + if (errorCode === ErrorCode.INVALID_OTP) { + return t('That code is invalid or expired. Try again.'); + } + if (errorCode === ErrorCode.DOMAIN_NOT_ALLOWED) { + return t('Email domain is disallowed'); + } + if (errorCode === ErrorCode.INVITATION_ONLY_SIGN_UP) { + return t('You need an invitation to sign up.'); + } + } + return t('Something went wrong, please try again later'); +} + +const VERIFIED_HOLD_MS = 1100; + +const PERSONAL_EMAIL_DOMAINS = new Set([ + 'gmail.com', + 'googlemail.com', + 'icloud.com', + 'me.com', + 'mac.com', + 'aol.com', + 'msn.com', + 'protonmail.com', + 'proton.me', + 'mail.com', + 'zoho.com', + 'yandex.com', + 'qq.com', + '163.com', + '126.com', + 'naver.com', + 'web.de', + 'orange.fr', + 'free.fr', +]); + +const PERSONAL_EMAIL_PREFIXES = [ + 'yahoo.', + 'hotmail.', + 'outlook.', + 'live.', + 'gmx.', +]; + +const EmailZodSchema = z.object({ + email: z.string().trim().regex(formatUtils.emailRegex, 'Email is invalid'), +}); + +type EmailSchema = z.infer; + +const FullNameZodSchema = z.object({ + fullName: z.string().trim().min(1).max(MAX_FULL_NAME_LENGTH), +}); + +type FullNameSchema = z.infer; + +type CodeStepProps = { + captchaToken: string | undefined; + captchaRequired: boolean; + onCaptchaSpent: () => void; + email: string; + onBack: () => void; + onNeedsName: () => void; +}; + +type EmailStepProps = { + invitedEmail: string; + captchaToken: string | undefined; + captchaRequired: boolean; + onCaptchaSpent: () => void; + onCodeSent: (email: string) => void; +}; + +type NameStepProps = { + onSessionRejected: () => void; +}; + +type AuthDrawerBodyProps = { + initialMode: AuthMode; +}; + +type AuthStepProps = { + step: Step; + setStep: Dispatch>; + samlOpen: boolean; + setSamlOpen: Dispatch>; + mode: AuthMode; + setMode: Dispatch>; + emailForCode: string; + setEmailForCode: Dispatch>; + checkEmailNote: boolean; + setCheckEmailNote: Dispatch>; + invitedEmail: string; + captchaToken: string | undefined; + captchaRequired: boolean; + onCaptchaSpent: () => void; +}; + +type Step = 'method' | 'code' | 'verified' | 'name' | 'password' | 'reset'; + +export type AuthMode = 'signin' | 'signup'; diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx new file mode 100644 index 000000000000..5779736a471c --- /dev/null +++ b/packages/web/src/features/authentication/components/auth-landing/auth-landing.tsx @@ -0,0 +1,98 @@ +import { isNil } from '@activepieces/core-utils'; +import { t } from 'i18next'; +import { useEffect, useRef } from 'react'; + +import { useTheme } from '@/components/providers/theme-provider'; +import { authenticationSession } from '@/lib/authentication-session'; +import { useRedirectAfterLogin } from '@/lib/navigation-utils'; + +import { AuthBackdrop } from './auth-backdrop'; +import { AuthDrawerBody, AuthMode } from './auth-drawer-body'; + +const NUDGE_STREAK_WINDOW_MS = 700; + +export function AuthLanding({ initialMode }: AuthLandingProps) { + const { setForceLightMode } = useTheme(); + const redirectAfterLogin = useRedirectAfterLogin(); + const signedIn = + !isNil(authenticationSession.getToken()) && + !authenticationSession.isOnboarding(); + const panelRef = useRef(null); + const nudgeRef = useRef<{ + lastAt: number; + streak: number; + animation: Animation | null; + }>({ + lastAt: 0, + streak: 0, + animation: null, + }); + + useEffect(() => { + setForceLightMode(true); + return () => setForceLightMode(false); + }, [setForceLightMode]); + + useEffect(() => { + if (signedIn) { + redirectAfterLogin(); + } + }, [signedIn, redirectAfterLogin]); + + if (signedIn) { + return null; + } + + const nudgePanel = () => { + const panel = panelRef.current; + if (!panel) { + return; + } + panel + .querySelector( + 'input:not([type="hidden"]):not([disabled])', + ) + ?.focus(); + const state = nudgeRef.current; + const now = performance.now(); + state.streak = + now - state.lastAt < NUDGE_STREAK_WINDOW_MS ? state.streak + 1 : 0; + state.lastAt = now; + const peak = Math.min(1.015 + state.streak * 0.008, 1.045); + state.animation?.cancel(); + state.animation = panel.animate( + [ + { transform: 'scale(1)' }, + { transform: `scale(${peak})`, offset: 0.3 }, + { transform: 'scale(0.997)', offset: 0.6 }, + { transform: 'scale(1)' }, + ], + { duration: 320, easing: 'ease-in-out' }, + ); + }; + + return ( +
+ +
+
+
+ +
+
+
+ ); +} + +type AuthLandingProps = { + initialMode: AuthMode; +}; diff --git a/packages/web/src/features/authentication/components/auth-landing/turnstile-widget.tsx b/packages/web/src/features/authentication/components/auth-landing/turnstile-widget.tsx new file mode 100644 index 000000000000..72658ebb2aaa --- /dev/null +++ b/packages/web/src/features/authentication/components/auth-landing/turnstile-widget.tsx @@ -0,0 +1,141 @@ +import { isNil } from '@activepieces/core-utils'; +import { ApFlagId } from '@activepieces/shared'; +import { t } from 'i18next'; +import { useEffect, useRef, useState } from 'react'; + +import { flagsHooks } from '@/hooks/flags-hooks'; + +const SCRIPT_ID = 'cf-turnstile'; +const SCRIPT_SRC = + 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + +// Loaded once per document and shared: mounting the widget twice (the card +// remounts on every step change) must not fetch or evaluate the script again. +let scriptPromise: Promise | null = null; + +function loadScript(): Promise { + if (scriptPromise) { + return scriptPromise; + } + scriptPromise = new Promise((resolve, reject) => { + const existing = document.getElementById(SCRIPT_ID); + if (existing) { + resolve(); + return; + } + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.src = SCRIPT_SRC; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('turnstile script failed to load')); + document.head.appendChild(script); + }); + return scriptPromise; +} + +export function useTurnstileSiteKey(): string | null { + const { data: siteKey } = flagsHooks.useFlag( + ApFlagId.TURNSTILE_SITE_KEY, + ); + return siteKey ?? null; +} + +export function TurnstileWidget({ + onToken, + onUnavailable, + resetSignal, +}: TurnstileWidgetProps) { + const siteKey = useTurnstileSiteKey(); + const container = useRef(null); + const widget = useRef(undefined); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!siteKey || !container.current) { + return; + } + let widgetId: string | undefined; + let cancelled = false; + + loadScript() + .then(() => { + if (cancelled || !container.current) { + return; + } + if (!window.turnstile) { + setFailed(true); + onUnavailable(); + return; + } + widgetId = window.turnstile.render(container.current, { + sitekey: siteKey, + callback: (token: string) => onToken(token), + 'expired-callback': () => onToken(undefined), + 'error-callback': () => onToken(undefined), + }); + widget.current = widgetId; + }) + .catch(() => { + if (!cancelled) { + setFailed(true); + onUnavailable(); + } + }); + + return () => { + cancelled = true; + widget.current = undefined; + if (widgetId && window.turnstile) { + window.turnstile.remove(widgetId); + } + }; + }, [siteKey, onToken, onUnavailable]); + + // A Turnstile token is single-use: once the server has rejected the request + // the widget has to issue a fresh one or every retry replays a spent token. + useEffect(() => { + if (resetSignal === 0 || isNil(widget.current) || !window.turnstile) { + return; + } + window.turnstile.reset(widget.current); + onToken(undefined); + }, [resetSignal, onToken]); + + if (!siteKey) { + return null; + } + // Say so rather than leaving a dead submit button: the server requires a + // solved challenge whenever one is configured, so a blocked script means + // sign-in cannot proceed and the person needs to know why. + if (failed) { + return ( +

+ {t( + 'The verification step could not load. Disable your ad blocker for this page, then reload.', + )} +

+ ); + } + return
; +} + +type TurnstileWidgetProps = { + onToken: (token: string | undefined) => void; + onUnavailable: () => void; + resetSignal: number; +}; + +declare global { + interface Window { + turnstile?: { + render: ( + element: HTMLElement, + options: Record, + ) => string; + remove: (widgetId: string) => void; + reset: (widgetId: string) => void; + }; + } +} diff --git a/packages/web/src/features/authentication/components/integration-logos-overlay.tsx b/packages/web/src/features/authentication/components/integration-logos-overlay.tsx deleted file mode 100644 index 6310dd5ebcaf..000000000000 --- a/packages/web/src/features/authentication/components/integration-logos-overlay.tsx +++ /dev/null @@ -1,45 +0,0 @@ -const CLIENTS = [ - { - name: 'MoneyGram', - src: 'https://www.activepieces.com/logos/moneygram.svg', - }, - { name: 'Red Bull', src: 'https://www.activepieces.com/logos/redbull.svg' }, - { name: 'Rakuten', src: 'https://www.activepieces.com/logos/rakuten.svg' }, - { name: 'DocuSign', src: 'https://www.activepieces.com/logos/docusign.svg' }, - { - name: 'Contentful', - src: 'https://www.activepieces.com/logos/contentful.svg', - }, - { name: 'PostHog', src: 'https://www.activepieces.com/logos/posthog.svg' }, - { name: 'Roblox', src: 'https://www.activepieces.com/logos/roblox.svg' }, - { name: 'Alan', src: 'https://www.activepieces.com/logos/alan.svg' }, - { - name: 'Funding Societies', - src: 'https://www.activepieces.com/logos/fundingsocieties-sales.png', - }, - { name: 'Plivo', src: 'https://www.activepieces.com/logos/plivo.svg' }, - { name: 'Nedap', src: 'https://www.activepieces.com/logos/nedap.svg' }, - { - name: 'Experience.com', - src: 'https://www.activepieces.com/logos/experience.com.svg', - }, -] as const; - -export const IntegrationLogosOverlay = () => { - return ( -
- {CLIENTS.map(({ name, src }) => ( - {name} { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - ))} -
- ); -}; diff --git a/packages/web/src/features/authentication/components/reset-password-form.tsx b/packages/web/src/features/authentication/components/reset-password-form.tsx index bd1960e5dfaf..52238d47c38b 100644 --- a/packages/web/src/features/authentication/components/reset-password-form.tsx +++ b/packages/web/src/features/authentication/components/reset-password-form.tsx @@ -24,7 +24,7 @@ import { HttpError } from '@/lib/api'; const FormSchema = z.object({ email: z.string().min(1, t('Please enter your email')), - type: z.nativeEnum(OtpType), + type: CreateOtpRequestBody.shape.type, }); type FormSchema = z.infer; diff --git a/packages/web/src/features/authentication/components/saml-login-form.tsx b/packages/web/src/features/authentication/components/saml-login-form.tsx index 99bf9ebaf236..08fdc53ead9c 100644 --- a/packages/web/src/features/authentication/components/saml-login-form.tsx +++ b/packages/web/src/features/authentication/components/saml-login-form.tsx @@ -25,9 +25,15 @@ type FormValues = z.infer; type SamlLoginFormProps = { onBack: () => void; + // The auth card supplies its own back affordance, so it opts out of this + // one rather than showing two. + showBackButton?: boolean; }; -export const SamlLoginForm = ({ onBack }: SamlLoginFormProps) => { +export const SamlLoginForm = ({ + onBack, + showBackButton = true, +}: SamlLoginFormProps) => { const form = useForm({ resolver: zodResolver(FormValues), defaultValues: { email: '' }, @@ -92,10 +98,12 @@ export const SamlLoginForm = ({ onBack }: SamlLoginFormProps) => { > {t('Continue')} - + {showBackButton && ( + + )} ); diff --git a/packages/web/src/features/authentication/components/sign-in-form.tsx b/packages/web/src/features/authentication/components/sign-in-form.tsx index 6ed1182bb9f6..66befacae33c 100644 --- a/packages/web/src/features/authentication/components/sign-in-form.tsx +++ b/packages/web/src/features/authentication/components/sign-in-form.tsx @@ -13,7 +13,7 @@ import { t } from 'i18next'; import { Eye, EyeOff } from 'lucide-react'; import { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { Link, Navigate, useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { z } from 'zod'; import { authenticationApi } from '@/api/authentication-api'; @@ -37,7 +37,7 @@ const SignInSchema = z.object({ type SignInSchema = z.infer; -const SignInForm: React.FC = () => { +const SignInForm = ({ onForgotPassword }: SignInFormProps) => { const [showCheckYourEmailNote, setShowCheckYourEmailNote] = useState(false); const [showPassword, setShowPassword] = useState(false); const form = useForm({ @@ -51,7 +51,6 @@ const SignInForm: React.FC = () => { const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); - const { data: userCreated } = flagsHooks.useFlag(ApFlagId.USER_CREATED); const redirectAfterLogin = useRedirectAfterLogin(); const navigate = useNavigate(); const { capture } = useTelemetry(); @@ -136,10 +135,6 @@ const SignInForm: React.FC = () => { mutate(data); }; - if (!userCreated) { - return ; - } - return ( <>
@@ -175,14 +170,25 @@ const SignInForm: React.FC = () => {
- {edition !== ApEdition.COMMUNITY && ( - - {t('Forgot your password?')} - - )} + {edition !== ApEdition.COMMUNITY && + // Inside the auth card the reset flow is another step, not + // another page — the caller hands us a handler for it. + (onForgotPassword ? ( + + ) : ( + + {t('Forgot your password?')} + + ))}
{ SignInForm.displayName = 'SignIn'; export { SignInForm }; + +type SignInFormProps = { + onForgotPassword?: () => void; +}; diff --git a/packages/web/src/features/authentication/components/sign-up-form.tsx b/packages/web/src/features/authentication/components/sign-up-form.tsx index e0f684dbd6fe..d3d5192cfe7b 100644 --- a/packages/web/src/features/authentication/components/sign-up-form.tsx +++ b/packages/web/src/features/authentication/components/sign-up-form.tsx @@ -7,7 +7,7 @@ import { } from '@activepieces/shared'; import { t } from 'i18next'; import { Eye, EyeOff } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -43,8 +43,14 @@ import { formatUtils } from '@/lib/format-utils'; import { useRedirectAfterLogin } from '@/lib/navigation-utils'; import { authMutations } from '../hooks/auth-hooks'; +import { captchaUtils } from '../utils/captcha-utils'; import { passwordValidation } from '../utils/password-validation-utils'; +import { + TurnstileWidget, + useTurnstileSiteKey, +} from './auth-landing/turnstile-widget'; + const SignUpForm = ({ showCheckYourEmailNote, setShowCheckYourEmailNote, @@ -108,6 +114,9 @@ const SignUpForm = ({ } }, onError: (error) => { + // The challenge token is single-use, so a refused attempt must be given a + // fresh one or every retry replays a token Cloudflare has already spent. + setCaptchaReset((count) => count + 1); if (api.isError(error)) { const errorCode: ErrorCode | undefined = ( error.response?.data as { code: ErrorCode } @@ -116,6 +125,12 @@ const SignUpForm = ({ name: TelemetryEventName.SIGN_UP_FAILED, payload: { errorCode: errorCode ?? 'UNKNOWN' }, }); + if (captchaUtils.isRejection(error)) { + form.setError('root.serverError', { + message: t('That verification expired. Please try again.'), + }); + return; + } if (isNil(errorCode)) { form.setError('root.serverError', { message: t('Something went wrong, please try again later'), @@ -164,6 +179,18 @@ const SignUpForm = ({ }, }); + const [captchaToken, setCaptchaToken] = useState(); + const [captchaUnavailable, setCaptchaUnavailable] = useState(false); + const [captchaReset, setCaptchaReset] = useState(0); + const handleCaptchaUnavailable = useCallback(() => { + setCaptchaUnavailable(true); + capture({ + name: TelemetryEventName.CAPTCHA_UNAVAILABLE, + payload: { surface: 'password-sign-up' }, + }); + }, [capture]); + const captchaRequired = !isNil(useTurnstileSiteKey()) && !captchaUnavailable; + const onSubmit: SubmitHandler = (data) => { form.setError('root.serverError', { message: undefined, @@ -176,6 +203,7 @@ const SignUpForm = ({ ...data, email: data.email.trim().toLowerCase(), trackEvents: true, + captchaToken, }); }; @@ -356,8 +384,14 @@ const SignUpForm = ({ {form.formState.errors.root.serverError.message} )} + )} - {isCloud && ( + {!hideSaml && isCloud && ( )} - {!isCloud && thirdPartyAuthProviders?.saml && ( + {!hideSaml && !isCloud && thirdPartyAuthProviders?.saml && (