diff --git a/.agents/skills/piece-builder/property-ui-selection.md b/.agents/skills/piece-builder/property-ui-selection.md index e670592fd0f6..3b52000e042d 100644 --- a/.agents/skills/piece-builder/property-ui-selection.md +++ b/.agents/skills/piece-builder/property-ui-selection.md @@ -86,9 +86,9 @@ These live directly on the property. They fine-tune placement without changing t | `placeholder` | string | Any text input — show an example value (`you@example.com`). | | `width` | `'half'` | Two short related fields should sit side-by-side (First / Last name). Only takes effect **inside a `section` group**. | | `icon` | icon name | Give a filter-builder row or section field a leading glyph. Must be a **valid name** — see §5. | -| `advanced` | `true` / `false` | `false` promotes a normally-optional field into the main form (e.g. a message body). `true` pushes an important-looking field into the collapsible **Advanced** section. | +| `advanced` | `true` | Tucks a secondary field into the collapsible **Advanced** section. Nothing collapses there unless you set it — `advanced: false` is the default and does nothing. | -**Advanced section rule:** non-required props collapse into *Advanced* by default. Reach for `advanced: false` when an optional field is actually central to the action. +**Advanced section rule:** every prop renders in the main form by default, required or not. Opt a field *out* with `advanced: true`. Don't set it on a required prop — the Advanced section starts collapsed, so a mandatory field hidden there only surfaces as a validation error. --- @@ -106,13 +106,14 @@ propertyGroups: [{ key, display, label?, description?, icon?, props: ['fieldA', | Intent | `display` | Behaviour | |---|---|---| | Mutually-exclusive **modes** of the same concept (To / Cc / Bcc; by-URL vs by-ID) | `'tabs'` | Segmented control; one tab's fields visible at a time. | -| **Related fields as a titled card** (a "Send to" card, a "Message" card) | `'section'` | Titled card; `width: 'half'` packs two-up. **Keeps the Advanced section** — ungrouped optional props still collapse as usual. | +| **Related fields as a titled card** (a "Send to" card, a "Message" card) | `'section'` | Titled card; `width: 'half'` packs two-up. **Keeps the Advanced section** for props outside the cards — group members are always essential. | | A **search/filter** action where users add only the filters they need | `'builder'` | Progressive "Add filter" picker; each `builder` group is a category. A filter row persists only when its value is set — give each filter a `placeholder` + `icon`. | -| A pinned control **below** a filter builder (result limit) | `'footer'` | Pins its prop (e.g. a `stepper`) under the builder list. Pair with `'builder'` groups. | +| A pinned control **below** a filter builder (result limit) | `'footer'` | Pins its prop (e.g. a `stepper`) under the builder list. Pair with `'builder'` groups — like `'builder'`, it disables the Advanced section form-wide. | **Rules:** - Every prop named in a group must exist in `props`. -- Props left out of every group follow the normal essential/Advanced rule (only `section` preserves this — `tabs` and `builder` take full control of their members). +- Only ungrouped props honour `advanced: true` — members of `tabs` and `section` groups are always essential; the flag is ignored on them. In a sectioned layout, checkbox `reveals` targets are forced essential too: they render inline under their toggle, never in Advanced. +- **One `builder` or `footer` group disables the Advanced section for the whole action/trigger** — every prop is forced essential and `advanced: true` stops working form-wide. Don't combine a filter builder with Advanced props. - Give `section` and `builder` groups a `label` and `icon` so cards/categories read clearly. --- @@ -140,7 +141,7 @@ props: { chat_id: Property.ShortText({ displayName: 'Chat Id', required: true, placeholder: '@channel or 123456789' }), format: Property.StaticDropdown({ displayName: 'Format', required: false, display: 'cards', options: { options: [/* Markdown / HTML / Plain */] } }), message: Property.RichText({ displayName: 'Message', required: true, formatProperty: 'format' }), - disable_notification: Property.Checkbox({ displayName: 'Disable notification', required: false }), // → Advanced + disable_notification: Property.Checkbox({ displayName: 'Disable notification', required: false, advanced: true }), // → Advanced (ungrouped + flagged) }, ``` @@ -179,5 +180,6 @@ props: { - **Invalid `icon` name.** Anything outside the §5 list silently renders nothing; verify before shipping. - **`Property.Json` as an escape hatch.** If the shape is known, model it with real props or an `Array` of fields. - **`Property.DynamicProperties` for a static form.** It's the heaviest widget; only use it when fields truly depend on runtime data. +- **`advanced: true` on a required prop.** Advanced starts collapsed; a mandatory field hidden there only surfaces as a validation error. Full type syntax and dynamic-dropdown/refresher mechanics: `props-patterns.md`. Rendered previews of every option: `docs/build-pieces/piece-reference/properties.mdx`. diff --git a/brain/knowledge/connections-auth/ce-authentication.md b/brain/knowledge/connections-auth/ce-authentication.md index add5156a971b..d6459d32c1d4 100644 --- a/brain/knowledge/connections-auth/ce-authentication.md +++ b/brain/knowledge/connections-auth/ce-authentication.md @@ -28,6 +28,8 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - **A new unauthenticated endpoint must be added to `disallowedRoutes` in `packages/web/src/lib/api.ts`**, otherwise the SPA attaches whatever stale bearer token is still in storage and the call fails in exactly the situation the endpoint exists for. - **The three signup guards in `authentication-utils.ts` differ in what they leak.** `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` describe platform configuration, so surfacing their errors is safe. `assertUserIsInvitedToPlatformOrProject` describes one address, so surfacing it turns any public auth endpoint into an invitation oracle. All three are also inert unless `plan.ssoEnabled`. - **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. +- **The route no longer decides sign-in vs sign-up — the card does.** `/sign-in`, `/sign-up` and `/create-platform` all render the same `AuthLanding`; `/sign-up` is a bare redirect to `/sign-in`. Which form you get is a function of two flags: with `SMTP_CONFIGURED` the card opens on the email-code step and the classic password form exists *only* behind the "Use password" link; without it you land on a password form directly, and `USER_CREATED` picks sign-up (first ever account, no mode switch offered) over sign-in. So the same URL renders three different DOMs across Cloud, a seeded self-host, and a fresh install — anything scripting this screen has to branch, and password sign-*up* is simply unreachable once SMTP is on. +- **`/create-platform` is that same card opening on its name step**, off the ONBOARDING token rather than a route param — submitting the name is what mints the platform and project and swaps ONBOARDING for USER. A brand-new account therefore needs *two* form submissions before it has a project, which is easy to miss when automating first-run signup. ### 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/engineering/e2e-tests-and-monitors.md b/brain/knowledge/engineering/e2e-tests-and-monitors.md new file mode 100644 index 000000000000..1ea87038f9c4 --- /dev/null +++ b/brain/knowledge/engineering/e2e-tests-and-monitors.md @@ -0,0 +1,28 @@ +--- +icon: 🎭 +--- + +# E2E Tests & Monitors + +One Playwright suite in `packages/tests-e2e` feeds three consumers that fail independently: CI on a fresh throwaway instance, Checkly monitors against **production Cloud**, and a single BetterStack monitor. A change that only breaks one of them looks green everywhere else, so it is worth knowing which reads what. + +**Local / CI suite** — `playwright.config.ts`, `testMatch: **/*.spec.ts`, split by `AP_EDITION` into `scenarios/ce` and `scenarios/ee`. Boots the whole stack itself via the `webServer` block. +**Checkly monitors** — `checkly.config.ts` picks up the *same* `**/scenarios/**/*.spec.ts` files and runs them every 10 minutes with `baseURL: https://cloud.activepieces.com`, signing in with `E2E_EMAIL` / `E2E_PASSWORD`. +**BetterStack monitor** — one standalone file, `scenarios/betterstack/*.flat.spec.js`. + +## Gotchas + +- **Checkly runs the shared specs against production Cloud**, so anything the page objects assume about the login screen has to hold on Cloud too — not just on the SMTP-less instance CI boots. This is the usual reason a UI change breaks the monitors but not CI. See the auth-card gotchas on [CE Authentication](../connections-auth/ce-authentication.md). +- **BetterStack does not read the repo — the repo pushes to it.** `.github/workflows/sync-betterstack-playwright.yml` fires on push to `main` and `PATCH`es the file's contents into the hardcoded monitor `4211060` as `playwright_script`. One-way and `main`-only: the monitor updates at *merge*, never on the PR, and any edit made in the BetterStack UI is silently overwritten by the next push. +- **The BetterStack file is deliberately flat and duplicated.** BetterStack executes one self-contained script, so it cannot `require` the `pages/` objects — its sign-in is a copy. Fix the page object and you have *not* fixed the monitor; both files need the change. +- **The `.flat.spec.js` runs nowhere else.** Playwright matches `*.spec.ts` and Checkly matches `**/scenarios/**/*.spec.ts`, so a broken flat file is invisible locally and in CI until it fails in BetterStack. +- **CI only runs on the `ready-for-e2e` label** (`e2e.yml` gates both edition workflows on it), which is why the suite can rot for weeks without anyone noticing. +- **Turbo strict env mode silently strips most of `.env.e2e`.** `globalPassThroughEnv` in `turbo.json` is an allow-list, so vars not named there never reach the `serve` tasks — verify with `tr '\0' '\n' < /proc//environ`. `AP_ENVIRONMENT` is among the casualties, so CI falls back to the `prod` default. Widening it to `AP_*` does forward them, but that alone broke worker→API Socket.IO auth (jobs queue up unconsumed), so the passthrough and the worker's `AP_WORKER_TOKEN` have to be sorted out together. +- **`AP_DEV_PIECES` loads from `packages/pieces/**/dist`, which `npm run dev` does not build.** Only pieces that happen to be build dependencies of api/worker have a `dist`, so a default dev instance serves **0 pieces** and every spec that picks a trigger times out on the piece search. Build them explicitly: `npx turbo run build --filter=@activepieces/piece-webhook --filter=@activepieces/piece-store`. +- **CE sign-up is invitation-only once a platform exists** (`INVITATION_ONLY_SIGN_UP`), so the suite's sign-up path only works on a genuinely fresh instance. Against a dev-seeded database, set `E2E_EMAIL` / `E2E_PASSWORD` instead — `global-setup.ts` prefers them and signs in rather than signing up. +- **Every workspace declares its own deps.** `@faker-js/faker` was imported by the page objects for months while only `server/api` declared it; under Bun's isolated linker that means the suite cannot import its own page objects at all. + +## Key files +- `packages/tests-e2e` — `playwright.config.ts` (local/CI), `checkly.config.ts` (Cloud monitors), `global-setup.ts` (provisions or signs in the seed account), `pages/` (shared page objects), `scenarios/betterstack/` (the standalone monitor script) +- `.github/workflows/e2e.yml` — the `ready-for-e2e` gate that calls the per-edition workflows +- `.github/workflows/sync-betterstack-playwright.yml` — the push-to-`main` upload diff --git a/brain/knowledge/engineering/index.md b/brain/knowledge/engineering/index.md index ecc04019f27f..47f707f5a294 100644 --- a/brain/knowledge/engineering/index.md +++ b/brain/knowledge/engineering/index.md @@ -33,4 +33,5 @@ The **Activepieces engineering brain**: how the system works, and *why* it was b - **Web Feature Anatomy** — the frontend feature folder, its barrel, route guards, and when a query gets the global error dialog - **Cloud Deployment Paths** — canary → prod, the `cloud-hotfix` override, and the breaking-migration gate that blocks both - **CI PR Review Hygiene** — draft-first Greptile review, the per-area PR size gate, and the workflow conventions reviewers keep re-litigating +- **E2E Tests & Monitors** — the one Playwright suite behind CI, the Checkly monitors that run it against production Cloud, and the BetterStack script the repo pushes on merge - **Architecture Spine** — the load-bearing structure of the codebase, and the gotchas that come with it: request-body `.max()` as data loss, TypeORM soft-delete across a canary window, and canary not proxying websockets diff --git a/bun.lock b/bun.lock index 677c132c410d..e0f522eb4072 100644 --- a/bun.lock +++ b/bun.lock @@ -10889,6 +10889,7 @@ "name": "tests-e2e", "version": "0.0.1", "devDependencies": { + "@faker-js/faker": "8.2.0", "@playwright/test": "1.54.1", "checkly": "6.9.10", "dotenv": "17.2.3", diff --git a/docs/build-pieces/piece-reference/properties.mdx b/docs/build-pieces/piece-reference/properties.mdx index 8e91210413a3..6ee9c8430e07 100644 --- a/docs/build-pieces/piece-reference/properties.mdx +++ b/docs/build-pieces/piece-reference/properties.mdx @@ -537,9 +537,9 @@ Every property accepts a few optional hints that fine-tune how it renders. They | `placeholder` | text inputs | Grey hint text shown inside an empty field (e.g. `you@example.com`). | | `width: 'half'` | any prop inside a group | Renders two fields side-by-side instead of full-width. | | `icon` | any prop | A named icon shown beside the field in the filter builder. | -| `advanced: false` | non-required props | Forces a normally-optional field to stay **outside** the collapsible *Advanced* section. | +| `advanced: true` | any prop | Moves the field into the collapsible *Advanced* section. Props render in the main form by default. | -Non-required properties are collapsed into an **Advanced** section by default. Set `advanced: false` to promote an important optional field (like a message body) back into the main form, or `advanced: true` to push a field into Advanced. +Every property renders in the main form by default, required or not. Set `advanced: true` on a secondary option to tuck it into the collapsible **Advanced** section — `advanced: false` is the default and has no effect. Avoid the flag on required props: the section starts collapsed, so a mandatory field hidden there only surfaces as a validation error. **Half-width fields** @@ -606,12 +606,12 @@ createAction({ ``` - A filter row is shown when its value is set, so there's nothing extra to persist. Give filters short `placeholder` hints and an `icon` so each row reads clearly. + A filter row is shown when its value is set, so there's nothing extra to persist. Give filters short `placeholder` hints and an `icon` so each row reads clearly. Note that a `builder` or `footer` group also switches off the *Advanced* section for the whole step — every prop lives in the builder. ### Sectioned cards -`display: 'section'` groups related props into titled cards — for example a *Send to* card and a *Message* card. Unlike tabs and the filter builder, sectioned layouts **keep the collapsible _Advanced_ section**: any prop you don't place in a section still follows the normal essential/advanced rule, so secondary options stay tucked away. Give each group a `label` and `icon`, and use `width: 'half'` on members to pack two fields per row. +`display: 'section'` groups related props into titled cards — for example a *Send to* card and a *Message* card. Unlike tabs and the filter builder, sectioned layouts **keep the collapsible _Advanced_ section** for props outside the cards: an ungrouped prop still honours `advanced: true` — unless it is a checkbox `reveals` target, which renders inline under its toggle instead. Props inside a section are always essential. Give each group a `label` and `icon`, and use `width: 'half'` on members to pack two fields per row. @@ -626,8 +626,8 @@ createAction({ chat_id: Property.ShortText({ displayName: 'Chat Id', required: true, placeholder: '@channelusername or 123456789' }), format: Property.StaticDropdown({ displayName: 'Format', required: false, display: 'cards', options: { options: [/* Markdown / HTML / Plain */] } }), message: Property.RichText({ displayName: 'Message', required: true, formatProperty: 'format' }), - // props left out of every group collapse into Advanced as usual - disable_notification: Property.Checkbox({ displayName: 'Disable notification', required: false }), + // ungrouped props can opt into Advanced with advanced: true + disable_notification: Property.Checkbox({ displayName: 'Disable notification', required: false, advanced: true }), }, }); ``` diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index 83b2aa16145e..2279bd757a52 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -55,6 +55,30 @@ This affects the Tables piece's Find Records action and any direct API call that Nothing on upgrade. Re-check any flow or API integration that filters a Date column with `gt`, `gte`, `lt` or `lte` — it now returns the rows the filter actually describes, which may be more or fewer than before. +#### Emailed sign-in codes allow ten wrong guesses per account per hour + +The six-digit login code already allowed five wrong guesses, but that budget lived on the code itself, and the fifth wrong guess threw the code away — so asking for a new code handed out five fresh guesses immediately, with no ceiling on how often that could repeat. A six-digit code is only a million possibilities, so unlimited retries reduce it to a matter of hours. + +Wrong guesses are now counted per account over a rolling hour, independently of how many codes get sent. Entering the right code clears the counter, so someone who fumbles a few digits and then succeeds is unaffected. + +Two consequences worth knowing. Someone who spends ten wrong guesses on an account within an hour cannot sign in **with an emailed code** until the hour is up; password and Google sign-in are unaffected. And because the counter is keyed on the account rather than the caller, anyone who knows an address can spend that budget on the owner's behalf — a temporary nuisance for the owner, and the trade the cap is worth making. + +#### What you need to do + +Nothing. The limit applies out of the box and needs no configuration. If your support team sees a report of "the code keeps saying it's wrong", have them check whether the account has burned its hourly budget, and point the user at password or Google sign-in in the meantime. + +#### One-time codes are no longer stored in a readable form + +The `otp` table used to hold the six-digit sign-in code as plain text, so anyone who could read the database — a replica, a backup, a support query — could sign in as any account for the ten minutes that code was alive, without a password. Codes are now stored as a digest keyed with a server-held secret, so reading the table no longer yields anything you can sign in with. The code itself is held only long enough to re-send it if the person asks for it again. + +A code found to be expired is thrown away when it is next presented, rather than sitting in the table until the same person happens to request another one. + +#### What you need to do + +Nothing, and no new configuration: the key is derived from a secret your instance already has. Codes written by an older build are recorded as such and keep working until they expire, so a sign-in already underway when you deploy still completes, an email verification link still opens, and a rolling deploy where both builds are serving at once behaves the same. + +Rolling back costs at most the codes issued after the deploy: the older build cannot read those, so whoever holds one asks for a fresh code. Nothing is rewritten and nothing is deleted, so no cleanup is needed either way. + ## 0.87.0 diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index cca67c6c1caa..8a0cb8f37d1d 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.137.0", + "version": "0.138.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", 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 c9175a17ffd0..9ebf7db1ed8c 100644 --- a/packages/core/shared/src/lib/ee/otp/otp-model.ts +++ b/packages/core/shared/src/lib/ee/otp/otp-model.ts @@ -16,6 +16,7 @@ export const OtpModel = z.object({ value: z.string(), state: z.nativeEnum(OtpState), attempts: z.number(), + version: z.number(), }) export type OtpModel = z.infer diff --git a/packages/pieces/framework/src/lib/piece-metadata.ts b/packages/pieces/framework/src/lib/piece-metadata.ts index 9c827cdba20d..cdf611c19195 100644 --- a/packages/pieces/framework/src/lib/piece-metadata.ts +++ b/packages/pieces/framework/src/lib/piece-metadata.ts @@ -58,9 +58,14 @@ export const AiMetadata = z.object({ }) export type AiMetadata = z.infer -export const ActionClassification = z.enum(['READ', 'WRITE']) +export const ActionClassification = z.enum(['READ', 'SEARCH', 'WRITE', 'DESTRUCTIVE']) export type ActionClassification = z.infer +export const READ_ONLY_CLASSIFICATIONS: readonly ActionClassification[] = ['READ', 'SEARCH'] + +export const isReadOnlyClassification = (classification: ActionClassification | undefined): boolean => + classification !== undefined && READ_ONLY_CLASSIFICATIONS.includes(classification) + export const PropertyGroupDisplay = z.enum(['tabs', 'section', 'summary', 'builder', 'footer']) export type PropertyGroupDisplay = z.infer diff --git a/packages/server/api/src/app/authentication/otp/otp-entity.ts b/packages/server/api/src/app/authentication/otp/otp-entity.ts index 35124eba68ce..fcde2eef7a7d 100644 --- a/packages/server/api/src/app/authentication/otp/otp-entity.ts +++ b/packages/server/api/src/app/authentication/otp/otp-entity.ts @@ -36,6 +36,11 @@ export const OtpEntity = new EntitySchema({ nullable: false, default: 0, }, + version: { + type: Number, + nullable: false, + default: 0, + }, }, indices: [ { diff --git a/packages/server/api/src/app/authentication/otp/otp-service.ts b/packages/server/api/src/app/authentication/otp/otp-service.ts index 69ab2afb93bc..76f00d8e4cfd 100644 --- a/packages/server/api/src/app/authentication/otp/otp-service.ts +++ b/packages/server/api/src/app/authentication/otp/otp-service.ts @@ -3,8 +3,9 @@ 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 { distributedLock, distributedStore } from '../../database/redis-connections' import { emailService } from '../../ee/helper/email/email-service' +import { encryptUtils } from '../../helper/encryption' import { userIdentityService } from '../user-identity/user-identity-service' import { otpGenerator } from './lib/otp-generator' import { OtpEntity } from './otp-entity' @@ -14,7 +15,10 @@ const OTP_EXPIRATION_MS: Record = { [OtpType.PASSWORD_RESET]: 10 * 60 * 1000, [OtpType.EMAIL_LOGIN]: 10 * 60 * 1000, } +const HASHED_OTP_VERSION = 1 const MAX_ATTEMPTS = 5 +const MAX_ATTEMPTS_PER_IDENTITY = 10 +const IDENTITY_BUDGET_WINDOW_SECONDS = 60 * 60 const repo = repoFactory(OtpEntity) @@ -28,43 +32,51 @@ export const otpService = (log: FastifyBaseLogger) => ({ if (!userIdentity) { return } - const existingOtp = await repo().findOneBy({ - identityId: userIdentity.id, - type, + const identityId = userIdentity.id + const code = await distributedLock(log).runExclusive({ + key: confirmLockKey({ identityId, type }), + timeoutInSeconds: 15, + fn: async () => { + const existingOtp = await repo().findOneBy({ identityId, type }) + const otpIsInFlight = !isNil(existingOtp) && existingOtp.state === OtpState.PENDING && !otpIsExpired(existingOtp) + const codeInFlight = otpIsInFlight ? await cachedCode({ identityId, type }) : null + if (!isNil(codeInFlight)) { + return codeInFlight + } + const freshCode = otpGenerator.generate({ type }) + const newOtp: Omit = { + id: apId(), + updated: dayjs().toISOString(), + type, + identityId, + value: await encryptUtils.hmacString(freshCode), + state: OtpState.PENDING, + attempts: 0, + version: HASHED_OTP_VERSION, + } + await repo().upsert(newOtp, ['identityId', 'type']) + await cacheCode({ identityId, type, code: freshCode }) + return freshCode + }, }) - 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, + otp: code, + type, }) }, async confirm({ identityId, type, value }: ConfirmParams): Promise { return distributedLock(log).runExclusive({ - key: `otp-confirm-${identityId}-${type}`, + key: confirmLockKey({ identityId, type }), timeoutInSeconds: 15, fn: async () => { + const spentOnIdentity = await guessesSpentOnIdentity({ identityId, type }) + if (spentOnIdentity >= MAX_ATTEMPTS_PER_IDENTITY) { + log.warn({ identityId, type }, '[otpService#confirm] identity guess budget exhausted, refusing') + return false + } const otp = await repo().findOneBy({ identityId, type }) if (isNil(otp)) { return false @@ -73,14 +85,20 @@ export const otpService = (log: FastifyBaseLogger) => ({ await discard({ otp, identityId, type, log }) return false } + if (otpIsExpired(otp)) { + 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) { + const otpMatches = encryptUtils.digestsMatch(otp.value, await comparableValue({ otp, value })) + if (otpMatches && otpIsPending) { await repo().delete({ id: otp.id }) + await forgetCachedCode({ identityId, type }) + await clearIdentityBudget({ identityId, type }) return true } await countAttempt(otp.id) + await countGuessOnIdentity({ identityId, type, spent: spentOnIdentity }) if (otp.attempts + 1 >= MAX_ATTEMPTS) { await discard({ otp, identityId, type, log }) } @@ -90,25 +108,97 @@ export const otpService = (log: FastifyBaseLogger) => ({ }, }) +async function comparableValue({ otp, value }: ComparableValueParams): Promise { + const writtenBeforeHashing = otp.version < HASHED_OTP_VERSION + return writtenBeforeHashing ? value : encryptUtils.hmacString(value) +} + 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') + await forgetCachedCode({ identityId, type }) + log.warn({ identityId, type }, '[otpService#confirm] credential discarded') +} + +function confirmLockKey({ identityId, type }: IdentityBudgetParams): string { + return `otp-confirm-${identityId}-${type}` +} + +function cachedCodeKey({ identityId, type }: IdentityBudgetParams): string { + return `otp-pending-code:${identityId}:${type}` +} + +async function cacheCode({ identityId, type, code }: CacheCodeParams): Promise { + const ttlSeconds = Math.ceil(OTP_EXPIRATION_MS[type] / 1000) + await distributedStore.put(cachedCodeKey({ identityId, type }), code, ttlSeconds) +} + +async function cachedCode({ identityId, type }: IdentityBudgetParams): Promise { + return distributedStore.get(cachedCodeKey({ identityId, type })) +} + +async function forgetCachedCode({ identityId, type }: IdentityBudgetParams): Promise { + await distributedStore.delete(cachedCodeKey({ identityId, type })) } function otpIsExpired(otp: OtpModel): boolean { return dayjs().diff(otp.updated, 'milliseconds') >= OTP_EXPIRATION_MS[otp.type] } +function identityBudgetKey({ identityId, type }: IdentityBudgetParams): string { + return `otp-guess-budget:${identityId}:${type}` +} + +async function guessesSpentOnIdentity({ identityId, type }: IdentityBudgetParams): Promise { + const budget = await distributedStore.get(identityBudgetKey({ identityId, type })) + return isNil(budget) ? 0 : budget.count +} + +async function countGuessOnIdentity({ identityId, type, spent }: CountGuessOnIdentityParams): Promise { + const key = identityBudgetKey({ identityId, type }) + const existing = await distributedStore.get(key) + const windowStartedAt = isNil(existing) ? Date.now() : existing.windowStartedAt + const elapsedSeconds = Math.floor((Date.now() - windowStartedAt) / 1000) + const remainingSeconds = Math.max(IDENTITY_BUDGET_WINDOW_SECONDS - elapsedSeconds, 1) + await distributedStore.put(key, { count: spent + 1, windowStartedAt }, remainingSeconds) +} + +async function clearIdentityBudget({ identityId, type }: IdentityBudgetParams): Promise { + await distributedStore.delete(identityBudgetKey({ identityId, type })) +} + type CreateParams = { platformId: PlatformId | null email: string type: OtpType } +type IdentityBudgetParams = { + identityId: string + type: OtpType +} + +type CountGuessOnIdentityParams = IdentityBudgetParams & { + spent: number +} + +type CacheCodeParams = IdentityBudgetParams & { + code: string +} + +type ComparableValueParams = { + otp: OtpModel + value: string +} + +type IdentityGuessBudget = { + count: number + windowStartedAt: number +} + type DiscardParams = { otp: OtpModel identityId: string diff --git a/packages/server/api/src/app/database/migration/postgres/1827000000000-AddVersionToOtp.ts b/packages/server/api/src/app/database/migration/postgres/1827000000000-AddVersionToOtp.ts new file mode 100644 index 000000000000..d9d3175b397c --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1827000000000-AddVersionToOtp.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' +import { system } from '../../../helper/system/system' + +const log = system.globalLogger() + +export class AddVersionToOtp1827000000000 implements MigrationInterface { + name = 'AddVersionToOtp1827000000000' + breaking = false + release = '0.88.1' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "otp" + ADD COLUMN "version" integer NOT NULL DEFAULT 0 + `) + log.info({ name: this.name }, 'otp version column added') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "otp" + DROP COLUMN "version" + `) + log.info({ name: this.name }, 'otp version column dropped') + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index dff4034d7fb5..134ecde16391 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -417,6 +417,7 @@ import { AddRenamedChatTableCompatViews1823000000000 } from './migration/postgre import { AddAttemptsToOtp1824000000000 } from './migration/postgres/1824000000000-AddAttemptsToOtp' import { AddAgentTable1825000000000 } from './migration/postgres/1825000000000-AddAgentTable' import { AddAgentIdToAgentConversation1826000000000 } from './migration/postgres/1826000000000-AddAgentIdToAgentConversation' +import { AddVersionToOtp1827000000000 } from './migration/postgres/1827000000000-AddVersionToOtp' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -849,6 +850,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddAttemptsToOtp1824000000000, AddAgentTable1825000000000, AddAgentIdToAgentConversation1826000000000, + AddVersionToOtp1827000000000, ] return migrations } 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 94218b55345e..301a280dce70 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 @@ -40,7 +40,7 @@ export const smtpEmailSender = (log: FastifyBaseLogger): SMTPEmailSender => { const senderEmail = system.get(AppSystemProp.SMTP_SENDER_EMAIL) if (!smtpEmailSender(log).isSmtpConfigured()) { - log.error({ emailSubject }, '[smtpEmailSender#send] SMTP is not configured') + log.error({ template: { name: templateData.name } }, '[smtpEmailSender#send] SMTP is not configured') return } @@ -53,7 +53,7 @@ export const smtpEmailSender = (log: FastifyBaseLogger): SMTPEmailSender => { log.info({ emails, platform: { id: platformId }, - templateData, + template: { name: templateData.name }, }, '[smtpEmailSender#send] sending email') await smtpClient.sendMail({ from: `${senderName} <${senderEmail}>`, diff --git a/packages/server/api/src/app/ee/helper/email/email-service.ts b/packages/server/api/src/app/ee/helper/email/email-service.ts index 95a0d6bc1779..c657c7bb2239 100644 --- a/packages/server/api/src/app/ee/helper/email/email-service.ts +++ b/packages/server/api/src/app/ee/helper/email/email-service.ts @@ -176,7 +176,6 @@ export const emailService = (log: FastifyBaseLogger) => ({ log.info({ email: userIdentity.email, - otp, identityId: userIdentity.id, type, }, 'Sending OTP email') diff --git a/packages/server/api/src/app/helper/encryption.ts b/packages/server/api/src/app/helper/encryption.ts index ce9f19af79d5..776fee196018 100644 --- a/packages/server/api/src/app/helper/encryption.ts +++ b/packages/server/api/src/app/helper/encryption.ts @@ -59,6 +59,16 @@ export const encryptUtils = { data: encrypted, } }, + hmacString: async (inputString: string): Promise => { + const secret = await encryptUtils.getEncryptionKey() + assertNotNullOrUndefined(secret, 'secret') + return crypto.createHmac('sha256', Buffer.from(secret, 'binary')).update(inputString).digest('hex') + }, + digestsMatch: (stored: string, candidate: string): boolean => { + const left = Buffer.from(stored, 'utf8') + const right = Buffer.from(candidate, 'utf8') + return left.length === right.length && crypto.timingSafeEqual(left, right) + }, getEncryptionKey: async (): Promise => { const secret = system.get(AppSystemProp.ENCRYPTION_KEY) ?? null if (!isNil(secret)) { diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index f699f61ea556..d66f027ec15f 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -16,6 +16,8 @@ import { pieceMetadataService } from '../../../src/app/pieces/metadata/piece-met export const CLOUD_PLATFORM_ID = 'cloud-id' +const HASHED_OTP_VERSION = 1 + export const createMockUserIdentity = (userIdentity?: Partial): UserIdentity => { return { id: userIdentity?.id ?? apId(), @@ -368,9 +370,16 @@ export const createMockOtp = (otp?: Partial): OtpModel => { otp?.value ?? faker.number.int({ min: 100000, max: 999999 }).toString(), state: otp?.state ?? faker.helpers.enumValue(OtpState), attempts: otp?.attempts ?? 0, + version: otp?.version ?? 0, } } +export const createMockOtpWithCode = async (otp?: Partial): Promise => { + const code = otp?.value ?? faker.number.int({ min: 100000, max: 999999 }).toString() + const value = await encryptUtils.hmacString(code) + return { otp: createMockOtp({ ...otp, value, version: HASHED_OTP_VERSION }), code } +} + export const createMockFlowRun = (flowRun?: Partial): FlowRun => { return { id: flowRun?.id ?? apId(), @@ -781,3 +790,8 @@ type MockBasicSetupParams = { platform?: Partial project?: Partial } + +type MockOtpWithCode = { + otp: OtpModel + code: string +} diff --git a/packages/server/api/test/integration/ce/authentication/otp-legacy-version.test.ts b/packages/server/api/test/integration/ce/authentication/otp-legacy-version.test.ts new file mode 100644 index 000000000000..2217b4121224 --- /dev/null +++ b/packages/server/api/test/integration/ce/authentication/otp-legacy-version.test.ts @@ -0,0 +1,87 @@ +import { apId, OtpState, OtpType } from '@activepieces/shared' +import dayjs from 'dayjs' +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 + +async function seedRow({ email, value, version, type = OtpType.EMAIL_LOGIN }: SeedParams): Promise { + const identity = createMockUserIdentity({ email, verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + await databaseConnection().getRepository('otp').save({ + id: apId(), + updated: dayjs().toISOString(), + type, + identityId: identity.id, + value, + state: OtpState.PENDING, + attempts: 0, + version, + }) + return identity.id +} + +function confirm({ identityId, value, type = OtpType.EMAIL_LOGIN }: ConfirmParams): Promise { + return otpService(app!.log).confirm({ identityId, type, value }) +} + +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('one-time codes written before hashing', () => { + it('accepts a code an older build stored in the clear, so a rollout in progress still signs people in', async () => { + const identityId = await seedRow({ email: 'legacy@example.com', value: '424242', version: 0 }) + + expect(await confirm({ identityId, value: '424242' })).toBe(true) + }) + + it('still refuses a wrong guess against a code written in the clear', async () => { + const identityId = await seedRow({ email: 'legacy-wrong@example.com', value: '424242', version: 0 }) + + expect(await confirm({ identityId, value: '424243' })).toBe(false) + }) + + it('does not read a hashed code as though it were written in the clear', async () => { + const identityId = await seedRow({ email: 'hashed@example.com', value: '424242', version: 1 }) + + expect(await confirm({ identityId, value: '424242' })).toBe(false) + }) + + it('marks every freshly issued code as hashed', async () => { + const email = 'fresh@example.com' + const identity = createMockUserIdentity({ email, verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + + await otpService(app!.log).createAndSend({ platformId: null, email, type: OtpType.EMAIL_LOGIN }) + + const row = await databaseConnection().getRepository('otp').findOneBy({ identityId: identity.id }) + expect(row?.version).toBe(1) + expect(row?.value).toMatch(/^[0-9a-f]{64}$/) + }) +}) + +type SeedParams = { + email: string + value: string + version: number + type?: OtpType +} + +type ConfirmParams = { + identityId: string + value: string + type?: OtpType +} 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 index 539320446a91..6e87a902f502 100644 --- a/packages/server/api/test/integration/ce/authentication/otp-service.test.ts +++ b/packages/server/api/test/integration/ce/authentication/otp-service.test.ts @@ -2,6 +2,7 @@ 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 { distributedStore } from '../../../../src/app/database/redis-connections' import { createMockUserIdentity } from '../../../helpers/mocks' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' @@ -9,6 +10,13 @@ let app: FastifyInstance | null = null const EMAIL = 'otp.budget@example.com' const MAX_ATTEMPTS = 5 +const MAX_ATTEMPTS_PER_IDENTITY = 10 + +async function issuedCode(): Promise { + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email: EMAIL }) + const code = await distributedStore.get(`otp-pending-code:${identity!.id}:${OtpType.EMAIL_LOGIN}`) + return code! +} async function seedIdentityWithCode(): Promise { const identity = createMockUserIdentity({ email: EMAIL, verified: true }) @@ -18,11 +26,7 @@ async function seedIdentityWithCode(): Promise { email: EMAIL, type: OtpType.EMAIL_LOGIN, }) - const otp = await databaseConnection().getRepository('otp').findOneBy({ - identityId: identity.id, - type: OtpType.EMAIL_LOGIN, - }) - return otp!.value + return issuedCode() } async function currentOtp() { @@ -55,6 +59,19 @@ async function sendCode(): Promise { }) } +async function burnOneCodeWithWrongGuesses(): Promise { + await sendCode() + const code = await issuedCode() + for (let guess = 0; guess < MAX_ATTEMPTS; guess++) { + await confirmCode(wrongVersionOf(code)) + } +} + +async function freshCorrectCode(): Promise { + await sendCode() + return issuedCode() +} + async function backdateCode(minutesAgo: number): Promise { const otp = await currentOtp() const sentAt = new Date(Date.now() - minutesAgo * 60 * 1000) @@ -79,10 +96,12 @@ beforeEach(async () => { describe('otpService#createAndSend', () => { it('re-sends the code already in flight instead of minting a second one', async () => { const issued = await seedIdentityWithCode() + const storedBefore = (await currentOtp())!.value await sendCode() - expect((await currentOtp())!.value).toBe(issued) + expect(await issuedCode()).toBe(issued) + expect((await currentOtp())!.value).toBe(storedBefore) }) it('mints a fresh code once the one in flight has expired', async () => { @@ -91,11 +110,53 @@ describe('otpService#createAndSend', () => { await sendCode() - expect((await currentOtp())!.value).not.toBe(issued) + expect(await issuedCode()).not.toBe(issued) + }) +}) + +describe('otpService#createAndSend at rest', () => { + it('never stores the code a person receives', async () => { + const issued = await seedIdentityWithCode() + + const stored = (await currentOtp())!.value + + expect(stored).not.toBe(issued) + expect(stored).toMatch(/^[0-9a-f]{64}$/) + }) + + it('hands out one code when two requests race, and that code works', async () => { + const identity = createMockUserIdentity({ email: EMAIL, verified: true }) + await databaseConnection().getRepository('user_identity').save(identity) + + await Promise.all([sendCode(), sendCode(), sendCode()]) + + const delivered = await issuedCode() + expect(await confirmCode(delivered)).toBe(true) + }) + + it('accepts the code it sent even though the row holds a digest', async () => { + const issued = await seedIdentityWithCode() + + expect(await confirmCode(issued)).toBe(true) + }) + + it('refuses the digest itself, offered as if it were the code', async () => { + await seedIdentityWithCode() + const stored = (await currentOtp())!.value + + expect(await confirmCode(stored)).toBe(false) }) }) describe('otpService#confirm', () => { + it('throws an expired code away rather than leaving it to linger', async () => { + const value = await seedIdentityWithCode() + await backdateCode(11) + + expect(await confirmCode(value)).toBe(false) + expect(await currentOtp()).toBeNull() + }) + it('accepts the correct code and consumes it', async () => { const value = await seedIdentityWithCode() @@ -145,6 +206,28 @@ describe('otpService#confirm', () => { expect(await confirmCode(value)).toBe(false) }) + it('refuses a correct code once the identity has spent its budget across several codes', async () => { + await seedIdentityWithCode() + const rounds = MAX_ATTEMPTS_PER_IDENTITY / MAX_ATTEMPTS + for (let round = 0; round < rounds; round++) { + await burnOneCodeWithWrongGuesses() + } + + const accepted = await confirmCode(await freshCorrectCode()) + + expect(accepted).toBe(false) + }) + + it('clears the identity budget when the right code lands, so an owner who fumbles is not locked out', async () => { + await seedIdentityWithCode() + await burnOneCodeWithWrongGuesses() + + expect(await confirmCode(await freshCorrectCode())).toBe(true) + + await burnOneCodeWithWrongGuesses() + expect(await confirmCode(await freshCorrectCode())).toBe(true) + }) + it('does not extend the life of a code by guessing at it', async () => { const value = await seedIdentityWithCode() const backdated = await backdateCode(9) 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 index 140731c9fb3e..a7464e72253e 100644 --- a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts +++ b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts @@ -6,6 +6,7 @@ import { passwordHasher } from '../../../../src/app/authentication/lib/password- import { otpService } from '../../../../src/app/authentication/otp/otp-service' import { userIdentityService } from '../../../../src/app/authentication/user-identity/user-identity-service' import { databaseConnection } from '../../../../src/app/database/database-connection' +import { distributedStore } from '../../../../src/app/database/redis-connections' import { passwordlessAuthService } from '../../../../src/app/authentication/passwordless-auth.service' import { platformService } from '../../../../src/app/platform/platform.service' import { createMockPlatform } from '../../../helpers/mocks' @@ -45,7 +46,7 @@ async function storedIdentity(email: string) { return databaseConnection().getRepository('user_identity').findOneBy({ email }) } -async function storedOtp(email: string) { +async function storedOtpRow(email: string) { const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email }) if (identity === null) { return null @@ -56,6 +57,15 @@ async function storedOtp(email: string) { }) } +async function storedOtp(email: string) { + const row = await storedOtpRow(email) + if (row === null) { + return null + } + const delivered = await distributedStore.get(`otp-pending-code:${row.identityId}:${OtpType.EMAIL_LOGIN}`) + return { ...row, value: delivered ?? row.value } +} + beforeAll(async () => { app = await setupTestEnvironment() }) @@ -87,6 +97,7 @@ describe('Passwordless Authentication API', () => { expect(otp?.value).toMatch(/^[0-9]{6}$/) expect(otp?.state).toBe(OtpState.PENDING) expect(otp?.attempts).toBe(0) + expect((await storedOtpRow(EMAIL))?.value).toMatch(/^[0-9a-f]{64}$/) }) it('seeds the name from the email local part until the name step runs', async () => { 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 3b853f5e3e80..c025b65e3827 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 @@ -3,7 +3,7 @@ import dayjs from 'dayjs' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { db } from '../../../helpers/db' -import { createMockOtp, mockBasicUser } from '../../../helpers/mocks' +import { createMockOtpWithCode, mockBasicUser } from '../../../helpers/mocks' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' let app: FastifyInstance | null = null @@ -26,7 +26,7 @@ describe('Enterprise Local Authn API', () => { verified: false, }, }) - const mockOtp = createMockOtp({ + const { otp: mockOtp, code: mockCode } = await createMockOtpWithCode({ identityId: mockUserIdentity.id, type: OtpType.EMAIL_VERIFICATION, state: OtpState.PENDING, @@ -35,7 +35,7 @@ describe('Enterprise Local Authn API', () => { const mockVerifyEmailRequest = { identityId: mockUserIdentity.id, - otp: mockOtp.value, + otp: mockCode, } // act @@ -65,7 +65,7 @@ describe('Enterprise Local Authn API', () => { }, }) const correctOtp = '123456' - const mockOtp = createMockOtp({ + const { otp: mockOtp, code: mockCode } = await createMockOtpWithCode({ identityId: mockUserIdentity.id, type: OtpType.EMAIL_VERIFICATION, value: correctOtp, @@ -105,7 +105,7 @@ describe('Enterprise Local Authn API', () => { }, }) - const mockOtp = createMockOtp({ + const { otp: mockOtp, code: mockCode } = await createMockOtpWithCode({ identityId: mockUserIdentity.id, type: OtpType.EMAIL_VERIFICATION, updated: dayjs().subtract(25, 'hours').toISOString(), @@ -115,7 +115,7 @@ describe('Enterprise Local Authn API', () => { const mockVerifyEmailRequest = { identityId: mockUserIdentity.id, - otp: mockOtp.value, + otp: mockCode, } // act @@ -144,7 +144,7 @@ describe('Enterprise Local Authn API', () => { }, }) - const mockOtp = createMockOtp({ + const { otp: mockOtp, code: mockCode } = await createMockOtpWithCode({ identityId: mockUserIdentity.id, type: OtpType.EMAIL_VERIFICATION, state: OtpState.CONFIRMED, @@ -153,7 +153,7 @@ describe('Enterprise Local Authn API', () => { const mockVerifyEmailRequest = { identityId: mockUserIdentity.id, - otp: mockOtp.value, + otp: mockCode, } // act @@ -179,7 +179,7 @@ describe('Enterprise Local Authn API', () => { userIdentity: { }, }) - const mockOtp = createMockOtp({ + const { otp: mockOtp, code: mockCode } = await createMockOtpWithCode({ identityId: mockUserIdentity.id, type: OtpType.PASSWORD_RESET, state: OtpState.PENDING, @@ -188,7 +188,7 @@ describe('Enterprise Local Authn API', () => { const mockResetPasswordRequest = { identityId: mockUserIdentity.id, - otp: mockOtp.value, + otp: mockCode, newPassword: 'newPassword', } @@ -213,7 +213,7 @@ describe('Enterprise Local Authn API', () => { }) const correctOtp = '123456' - const mockOtp = createMockOtp({ + const { otp: mockOtp, code: mockCode } = await createMockOtpWithCode({ identityId: mockUserIdentity.id, type: OtpType.PASSWORD_RESET, value: correctOtp, diff --git a/packages/tests-e2e/package.json b/packages/tests-e2e/package.json index 27ca18204ede..dc1e738d80a4 100644 --- a/packages/tests-e2e/package.json +++ b/packages/tests-e2e/package.json @@ -8,6 +8,7 @@ }, "dependencies": {}, "devDependencies": { + "@faker-js/faker": "8.2.0", "@playwright/test": "1.54.1", "checkly": "6.9.10", "dotenv": "17.2.3" diff --git a/packages/tests-e2e/pages/authentication.page.ts b/packages/tests-e2e/pages/authentication.page.ts index ac144fbd6e91..1cd447c49976 100644 --- a/packages/tests-e2e/pages/authentication.page.ts +++ b/packages/tests-e2e/pages/authentication.page.ts @@ -7,20 +7,24 @@ export class AuthenticationPage extends BasePage { async signIn(params: { email: string; password: string }) { await this.page.goto(this.url); - + await this.openPasswordForm(); + await this.switchToSignInForm(); + const emailField = this.page.getByTestId('sign-in-email'); await emailField.click(); await emailField.fill(params.email); - + const passwordField = this.page.getByTestId('sign-in-password'); await passwordField.click(); await passwordField.fill(params.password); - + await this.page.getByTestId('sign-in-button').click(); } async signUp(params?: { email?: string; password?: string; firstName?: string; lastName?: string }) { await this.page.goto(this.signUpUrl); + await this.openPasswordForm(); + await this.switchToSignUpForm(); const firstNameField = this.page.getByTestId('sign-up-first-name'); await firstNameField.click(); @@ -46,5 +50,89 @@ export class AuthenticationPage extends BasePage { })); await this.page.getByTestId('sign-up-button').click(); + + await this.completeOnboardingName( + `${params?.firstName || 'Bugs'} ${params?.lastName || 'Bunny'}`, + ); + } + + private async completeOnboardingName(fullName: string) { + const nameField = this.page.getByTestId('auth-full-name'); + + const nameStepShown = nameField + .waitFor({ timeout: 30000 }) + .catch(() => undefined); + const leftOnboarding = this.page + .waitForURL( + (url) => + !ONBOARDING_PATHS.some((path) => url.pathname.startsWith(path)), + { timeout: 30000 }, + ) + .catch(() => undefined); + await Promise.race([nameStepShown, leftOnboarding]); + + if (!(await nameField.count())) { + return; + } + + await nameField.fill(fullName); + await this.page.getByTestId('auth-name-continue').click(); } -} \ No newline at end of file + + private passwordFormField() { + return this.page + .getByTestId('sign-in-email') + .or(this.page.getByTestId('sign-up-email')); + } + + private async openPasswordForm() { + const usePasswordLink = this.page + .getByTestId('auth-use-password') + .or(this.page.getByRole('button', { name: 'Use password' })); + await this.passwordFormField() + .or(usePasswordLink) + .first() + .waitFor({ timeout: 30000 }); + + if (await usePasswordLink.count()) { + await usePasswordLink.first().click(); + await this.passwordFormField().first().waitFor(); + } + } + + private async switchToSignInForm() { + const signInEmail = this.page.getByTestId('sign-in-email'); + if (await signInEmail.count()) { + return; + } + + const switchMode = this.page.getByTestId('auth-switch-mode'); + if (!(await switchMode.count())) { + throw new Error( + 'The auth card offers no password sign-in: this instance has no account yet, so it only allows creating the first one.', + ); + } + + await switchMode.click(); + await signInEmail.waitFor(); + } + + private async switchToSignUpForm() { + const signUpEmail = this.page.getByTestId('sign-up-email'); + if (await signUpEmail.count()) { + return; + } + + const switchMode = this.page.getByTestId('auth-switch-mode'); + if (!(await switchMode.count())) { + throw new Error( + 'Password sign-up is unreachable on this instance: the auth card offers only the email-code flow. Set E2E_EMAIL and E2E_PASSWORD to sign in with an existing account instead.', + ); + } + + await switchMode.click(); + await signUpEmail.waitFor(); + } +} + +const ONBOARDING_PATHS = ['/sign-in', '/sign-up', '/create-platform']; diff --git a/packages/tests-e2e/pages/automations.page.ts b/packages/tests-e2e/pages/automations.page.ts index 58c46c1ccd92..64cbf1d374ac 100644 --- a/packages/tests-e2e/pages/automations.page.ts +++ b/packages/tests-e2e/pages/automations.page.ts @@ -14,7 +14,8 @@ export class AutomationsPage extends BasePage { await this.page.waitForURL( (url) => !url.pathname.includes('/sign-in') && - !url.pathname.includes('/sign-up'), + !url.pathname.includes('/sign-up') && + !url.pathname.includes('/create-platform'), { timeout: 15000 }, ); await this.visit(); diff --git a/packages/tests-e2e/scenarios/betterstack/webhook-should-return-response.flat.spec.js b/packages/tests-e2e/scenarios/betterstack/webhook-should-return-response.flat.spec.js index 5b5b81724034..caea3b552018 100644 --- a/packages/tests-e2e/scenarios/betterstack/webhook-should-return-response.flat.spec.js +++ b/packages/tests-e2e/scenarios/betterstack/webhook-should-return-response.flat.spec.js @@ -9,7 +9,16 @@ test('should handle webhook with return response', async ({ page }) => { // Sign in await page.goto(`${baseUrl}/sign-in`); - await page.getByTestId('sign-in-email').fill(email); + const passwordEmail = page.getByTestId('sign-in-email'); + const usePassword = page + .getByTestId('auth-use-password') + .or(page.getByRole('button', { name: 'Use password' })); + await passwordEmail.or(usePassword).first().waitFor({ timeout: 30000 }); + if (await usePassword.count()) { + await usePassword.first().click(); + await passwordEmail.waitFor(); + } + await passwordEmail.fill(email); await page.getByTestId('sign-in-password').fill(password); await page.getByTestId('sign-in-button').click(); diff --git a/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx b/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx index da377a841086..fae05d9c3961 100644 --- a/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx +++ b/packages/web/src/app/builder/pieces-selector/generic-piece-selector-item.tsx @@ -1,3 +1,4 @@ +import type { ActionClassification } from '@activepieces/pieces-framework'; import { FlowActionType, FlowTriggerType } from '@activepieces/shared'; import { t } from 'i18next'; @@ -77,12 +78,15 @@ const GenericActionOrTriggerItem = ({ {pieceSelectorItemInfo.classification && ( - {pieceSelectorItemInfo.classification === 'READ' - ? t('Read') - : t('Write')} + {CLASSIFICATION_BADGE[ + pieceSelectorItemInfo.classification + ].label()} )} @@ -103,3 +107,13 @@ const GenericActionOrTriggerItem = ({ GenericActionOrTriggerItem.displayName = 'GenericActionOrTriggerItem'; export default GenericActionOrTriggerItem; + +const CLASSIFICATION_BADGE: Record< + ActionClassification, + { label: () => string; variant: 'accent' | 'destructive' } +> = { + READ: { label: () => t('Read'), variant: 'accent' }, + SEARCH: { label: () => t('Search'), variant: 'accent' }, + WRITE: { label: () => t('Write'), variant: 'accent' }, + DESTRUCTIVE: { label: () => t('Destructive'), variant: 'destructive' }, +}; diff --git a/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx index 6bba0d4a0449..2dd0c02e80b9 100644 --- a/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx +++ b/packages/web/src/features/authentication/components/auth-landing/auth-drawer-body.tsx @@ -406,6 +406,7 @@ function AuthStep({