Skip to content

Bump version v2.10.1 - #205

Merged
RishadAlam merged 29 commits into
mainfrom
redesign/settings-doc-support
Jul 30, 2026
Merged

Bump version v2.10.1#205
RishadAlam merged 29 commits into
mainfrom
redesign/settings-doc-support

Conversation

@RishadAlam

Copy link
Copy Markdown
Member

Description

Ships the 2.10.1 release of the free plugin: dynamic URL path variables and smart codes for outgoing webhooks, connection switching and clearer connection state on the integration info page, a round of security hardening around custom actions and AJAX route capabilities, and a styling pass that puts every page and table on a shared canvas.

Motivation & Context

2.10.0 introduced Connections but left gaps: an action's connection could not be changed after saving, integrations created before 2.10.0 showed an unexplained empty credentials panel, and renamed integrations failed to resolve their stored credentials. Outgoing webhooks could only target a static URL, which made REST-style endpoints (/users/{id}/orders) unusable without a separate flow per record. Separately, a review of the custom-action path turned up an administrator gate that could be bypassed by spelling the action type differently, plus several weaker auth boundaries worth closing in the same pass.

Related Links: (if applicable)

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 📚 Documentation update
  • ⚡ Improvement
  • 🔄 Code refactor

Key Changes

Webhooks

  • Added dynamic URL path variables — a {name} placeholder anywhere in the outgoing webhook path becomes a mappable field filled from trigger data
  • Added smart code support in query parameters, request headers and path variables
  • Fixed run success/failure now judged by the response HTTP status code instead of the response body
  • Fixed path variables sync with the URL even when their tab is never opened

Connections

  • Added connection switching directly from an action's info page
  • Added an explainer on the info page for integrations saved before 2.10.0, which keep credentials inline and therefore have no connection to display or switch
  • Added a loading state while the connections list is fetched
  • Fixed credential resolution for integrations that were renamed
  • Updated the manage-connections link to render as a button

Security

  • Fixed custom-action administrator gate bypass — the gate and Flow::execute() normalized the action type differently, so customAction / Custom Action skipped the manage_options check while still resolving to CustomActionController; both now share Flow::normalizeActionType()
  • Fixed the custom-function include is confined via CustomFuncValidator::resolveCustomFunctionFile() (realpath, .php extension required, stream wrappers rejected, must live inside the plugin's custom-function directory), applied at both include sites
  • Fixed log re-execution now applies the same custom-action gate as save/update/delete/toggle
  • Fixed integration-owned routes moved to a write-only baseline via Route::defaultAccess(), set centrally in HookService
  • Fixed Mail validateAddresses() now filters the scalar branch too, and sanitizes header display names
  • Fixed scrape keys and validation temp filenames use random_bytes(16) instead of md5(wp_rand())
  • Fixed the custom-function ABSPATH guard must now be a real guard that exits, with nothing executable before it
  • Fixed trigger ids filtered before interpolation into _test option names; _bi_random_digit_num no longer returns time()
  • Fixed admin submenu registered only when the capability check passes

Frontend / Styling

  • Updated every page and table onto the shared canvas
  • Updated all popover menus to open the way the row-action menu does
  • Fixed multiselect fields get a white field and matching border
  • Removed dead rules from app.scss

Docs & Chores

  • Updated action documentation links, including the ones that were never wired
  • Added SPDX license identifier
  • Updated banner asset
  • Updated version to 2.10.1 and added the release changelog

Checklist

  • Code follows project style guidelines
  • Self-review completed
  • Tests added/updated
  • Documentation updated if needed
  • README updated if needed

Changelog

  • Feature: Outgoing webhooks support dynamic URL path variables, mappable from trigger data
  • Feature: Smart codes are available in webhook query parameters, request headers and path variables
  • Feature: An action's connection can be switched from its info page
  • Improvement: Connections list shows a loading state, and legacy integrations explain why their credentials are not displayed
  • Improvement: Redesigned page and table styling with a shared canvas, consistent popover menus and restyled multiselect fields
  • Improvement: Action documentation links updated, including the previously missing ones
  • Fix: Credentials now resolve correctly for renamed integrations
  • Fix: Legacy actions no longer render a blank info page
  • Fix: Webhook run status is judged by the response HTTP status code
  • Fix: ACF field reading guarded when the meta value is missing
  • Security: Closed a custom-action administrator gate bypass and confined the custom function include to the plugin's custom-function directory
  • Security: Log re-execution now applies the custom-action administrator check
  • Security: Integration AJAX routes require a write capability instead of view-only access
  • Security: Mail recipient addresses and headers validated in all cases; header display names sanitized
  • Security: Scrape keys and temporary file names use cryptographically secure random values

…ries

The custom-action administrator gate and the flow dispatcher normalized the
action type differently: the gate compared flow_details->type verbatim while
Flow::execute() ran it through ucfirst(str_replace(' ', '', ...)). A type of
"customAction" or "Custom Action" therefore skipped the manage_options check yet
still resolved to CustomActionController, which include()s a path carried in the
same caller-supplied JSON. Both sides now share Flow::normalizeActionType(), and
the include is confined independently: CustomFuncValidator::resolveCustomFunctionFile()
resolves through realpath(), requires a .php extension, rejects stream wrappers,
and demands the result sit inside the plugin's custom-function directory. It is
applied at both include sites, so neither one trusts an existing file path alone.

Log re-execution runs a flow's action for real but skipped the custom-action
gate that save/update/delete/toggle already apply, letting manage_integrations
alone invoke admin-authored PHP. It now calls the same guard.

Other fixes in the same area:

- Scrape key and validation temp filename used md5(wp_rand()); wp_rand() returns
  an int below mt_getrandmax(), leaving ~2^31 keys to enumerate against an
  unauthenticated route. Both use random_bytes(16).
- The custom-function ABSPATH check matched the substring anywhere in the file,
  which a comment satisfied. The directory's .htaccess/web.config guards do not
  apply on nginx, where uploads/**/*.php is normally passed to PHP-FPM, so the
  in-file guard is the only portable defence. It must now be a real guard that
  exits, with nothing executable preceding it.
- Mail: only the array branch of validateAddresses() applied is_email(), so a
  scalar recipient or header was interpolated from form input unchecked. Both
  branches filter now, and the header display name is sanitized.
- Integration-owned routes (per-action and per-trigger Routes.php) authorize
  credentials and call third-party APIs with no capability check of their own,
  yet the route baseline accepted view_integrations. They are registered under a
  write-only baseline via Route::defaultAccess(), set centrally in HookService so
  no integration file changes.
- Trigger ids are filtered before being interpolated into a _test option name.
- Hash::secretKey() honours a BIT_INTEGRATIONS_SECRET_KEY constant, but only when
  no key has been persisted yet: switching keys under existing ciphertext would
  make every stored credential undecryptable.
- _bi_random_digit_num returned time(), fully predictable despite its name and a
  hazard when mapped into a token or reference field. Use _bi_current_time for a
  timestamp.
Outgoing webhooks could only resolve smart tags inside the query string,
so a REST endpoint like /v1/users/{id}/orders had no way to take its id
from trigger data.

The url path now resolves two notations:
- {name}   mapped to a value through the new `pathParams` config
- ${field} smart tag written inline in the path

Resolution happens after wp_parse_url() and only on the path component,
so a dynamic value can never rewrite the scheme, host or port. Values are
raw url encoded, keeping them inside the single segment they were written
in (no injected segments, query or fragment). A mapped variable that
resolves empty aborts the request with a WP_Error and logs it, instead of
firing a malformed url; the Test Webhook run keeps such placeholders
literal since it has no trigger data. Unmapped placeholders are left
untouched, so existing flows behave as before.

Frontend adds a Path Variables tab that reads the variables straight out
of the url, keeps `pathParams` in sync with it and maps each one to a
trigger field. Inherited by every webhook based integration (IFTTT, N8n,
Pabbly, WPFusion, ...) since they share the same component.

Also fixes an undefined $response when the url was empty and a stray
trailing "?" when the webhook had no query parameters.
Only the Body tab exposed smart codes; the field picker in Params,
Headers and Path Variables listed trigger fields only, even though the
backend already resolves smart codes in those places through
Common::replaceFieldWithValue().

Replace the MultiSelect in all three with the same native select the Body
tab uses, listing FlowFormFieldsOptions plus SmartTagOptions, so every
webhook field picker offers the same values. Smart codes stay Pro gated
by SmartTagOptions itself.

Both option groups read from recoil, so the formFields prop those three
components no longer need is dropped along with it.
Panel from @bumaga/tabs renders null while another tab is active, so
<PathParams /> was unmounted and the effect creating `pathParams` only ran
once the user actually visited the tab. Writing {id} in the url and saving
from any other tab left the variable unmapped, and the request went out
with the literal brace in it instead of being skipped.

Move that effect into usePathParamsSync() and call it from
WebHooksIntegration, which is always mounted. An unmapped variable now
resolves to an empty value, which aborts and logs the run as intended.
The hook is disabled in info view so read only rendering never writes to
the config.

Also report every empty variable in the error instead of only the last one
found, and log both url failures as the WP_Error itself, matching the
shape the existing http error path already saves.
A remote answering 404 or 500 still hands back a decoded body, so a run
was logged as a success unless the body happened to carry an `error` key.
Test Webhook reported the same way, telling the user the endpoint worked
when it had just refused the request.

Read HttpHelper::$responseCode after the request and treat anything
outside 200-299 as a failure. An unknown status is not a failure on its
own, so runs that logged success before only flip when the remote really
answered non-2xx. Failed runs now log the status next to the response, and
Test Webhook reports it in both directions.

The body's `error` property is read through an is_object() guard as well;
on a plain text response the old check raised a php 8 warning.
The info page rendered each integration's authorization step read-only,
so pointing a saved flow at another account meant walking the whole edit
wizard. Enable just the connection dropdown there, including creating a
new connection, and persist the pick through flow/update.

Switch mode travels by context rather than a new prop, so the ~164
integration wrappers that forward isInfo stay untouched. The sub-forms
write config after saving and the info page has no integration setter,
so those writes are captured and merged into the flow update instead.

Because the field mapping still targets the old account, a Next button
appears after a switch and hands the user off to the edit wizard.

Also fixed while wiring this up:
- the create form no longer opens itself on the info page when an
  integration's connection list comes back empty
- IntegInfo's SWR key is now scoped by flow id, so one flow's cached
  response can't back another flow's update
IntegrationInfo's type switch fell through to an empty fragment for any
action it had no case for, so 17 of the flow types in a real install
opened a blank page.

Two causes. Some stored type strings never matched their case label
("Tutor Lms", "Wishlist Member", "Fluent Crm", "Ant Apps"), so those get
alias cases. Others had an authorization component that was simply never
mapped: LearnDash, LifterLms, GamiPress, Affiliate, BuddyBoss, SliceWp
and CustomApi.

What remains unmapped — site-local actions with no credentials at all,
and anything this build ships no component for — now falls back to a
read-only card naming the integration and linking to its settings,
instead of rendering nothing.

CustomApi's Next button called setStep, which the info page never passes,
so it is hidden under isInfo the way the shared authorization step
already hides its own.
Settings and Documentation & support painted themselves #f8f9fd while
every other route showed the white .route-wrp shell through, so moving
between them flickered between two backgrounds.

Give the whole app that canvas instead: the plugin root, the route shell
and the table surfaces all read from $pg-canvas, which moves up to the
global variables now that it is no longer a two-page concern.

Table hover needed fixing to survive the change. It was #fbfcff, lighter
than the canvas and so invisible, and the cells painted an opaque
background over the row's hover anyway — only the cell under the cursor
went transparent. Rows now hover a step darker than the canvas and the
cells stay transparent, so the whole row lights up.
The stylesheet still carried most of its Bit Form builder ancestry:
block/sidebar/accordion/color-picker/date-picker rules whose markup this
plugin never renders, styles for react-date-picker and react-time-picker
which are not even dependencies, and a tag-assign set that the live tag
UI replaced. 8868 lines down to 6494; compiled CSS 121KB to 91KB.

Selectors were resolved through their & nesting and kept if the class
appears anywhere in the frontend, the plugin PHP or the Pro plugin, or if
it is applied at runtime rather than written in markup: WordPress core
classes, classes shipped by a dependency's own stylesheet (this is what
saves the msl-* multiselect rules), the -enter/-exit variants that
react-transition-group synthesises from a base name, and names built by
template literal such as btcd-opt-icn--${tone}. A block went only when
every class in its selector failed all of those.

Also removed 15 blocks duplicated verbatim (including a ~90-line
tutoriallink/ai-tool section), 47 commented-out declarations and 5 unused
variables. Same-selector blocks with differing bodies were left alone;
they are cascade layering, not duplication.

Verified by diffing the compiled CSS rule by rule: 330 rules gone, none
added, no declaration body altered.
react-multiple-select-dropdown-lite paints a background only on the
active and disabled control, so an idle dropdown was transparent — which
read as white until the pages moved onto the #f8f9fd canvas. Its border
is also #9c9c9c, darker than the #e2e2e2 every other input in the plugin
uses.

.btcd-paper-drpdwn already corrected both, but plenty of integrations
render the dropdown without that wrapper ("w-5", "mt-2 w-5",
"msl-wrp-options"), so match the library's own markup instead. It has to
out-specify a bare .msl: the library's border shorthand would overwrite a
border-color at equal specificity, and its stylesheet ships in a lazy
chunk, so it loads after this file and wins ties. Its
`.msl-disabled > .msl` grey stays more specific, so read-only fields keep
looking disabled.
Replace PHP functions that have WordPress equivalents across backend/,
following up on a security scan that flagged them.

Add Core/Util/FileSystem as the single owner of local file access:

- instance() always returns a WP_Filesystem_Base, falling back to
  WP_Filesystem_Direct when WP_Filesystem() cannot get FTP/SSH
  credentials. Every path handled is local, so callers no longer have to
  branch on init failure, and a credential-less site no longer uploads an
  empty file body.
- Removes the duplicate private getFilesystem() in CustomFuncValidator,
  along with its two now-unreachable "Unable to initialize filesystem"
  paths. DISALLOW_FILE_MODS is still gated by fileModsDisabled().

Function swaps:

- file_get_contents()/file_put_contents() -> FileSystem::read()/write()
  (15 and 2 sites). Semantics are unchanged: read() returns string|false
  like file_get_contents(), so no call site needed new error handling.
  Route.php keeps file_get_contents('php://input') -- the filesystem API
  cannot read PHP streams.
- json_encode() -> wp_json_encode() (11 sites).
- urlencode() -> rawurlencode() (8 sites). In Hash::encrypt() this is
  safe both ways: base64 output never contains a space, the only
  character where the two differ, so decrypt()'s urldecode() still reads
  envelopes written by either version.

Also drop commented-out dead code from the touched files, including the
disused sideBarMenu() block in Config. phpcs directives, translator
comments and rationale comments are kept.
The $submenu global was written unconditionally, so a user without the
manage_wp_integrations capability got submenu entries recorded against a
parent menu that add_menu_page() had never registered. Move the write
inside the capability check alongside it.

Keep the direct $submenu assignment rather than switching to
add_submenu_page(): add_menu_page() auto-creates a first submenu entry
duplicating the parent, and overwriting the array is what replaces it.
Note the reason inline.
"GPLv2 or later" is not a recognised SPDX identifier. Use
GPL-2.0-or-later and add the License URI header, both of which
WordPress.org expects. Declared license is unchanged.
get_post_meta() returns an empty array for a field with no stored value,
so indexing [0] unconditionally emitted an "Undefined offset" warning and
produced no value. Fall back to null instead.
…info

Integrations saved before 2.10.0 keep their credentials inline in
flow_details rather than pointing at a connection row, so the info page
renders a connection dropdown with nothing behind it. Users read the
blank state as lost credentials.

Add a notice on the info page for flows with no connection_id: why the
fields are empty, that the integration still runs unchanged, and how to
move it onto a saved connection. It also introduces the connection
manager itself — authorize an app once and reuse that connection across
every integration for the same app.

Scoped to the shared Authorization view and skipped for wp_plugin_check
auth types, which have no credentials to explain.
The connection dropdown rendered empty and interactive while the saved
connections were still being fetched, so the list looked like it had no
entries until the request landed.

- Authorization: start isLoading as true so the first paint reflects the
  in-flight fetch; clear it on the early-return paths (missing app slug,
  WP plugin check) so it cannot stick
- Authorization: stop folding isLoading into isInfo — that hid the
  refresh button during load, leaving nowhere to surface a spinner
- ConnectionAccountSelect: swap the refresh glyph for LoaderSm, disable
  the dropdown, and switch the placeholder to "Loading connections..."
- Add a delayed fade-in helper text so fast loads never flash it, with a
  reduced-motion override
The link sat at the end of a long explainer as underlined text, so the
one action the notice offers read as body copy rather than a control.

- Fill it as a pill button: solid accent background, white label, 8px
  radius, inline-flex so the padding actually applies
- Add hover darken + soft shadow, a scale(0.97) press state, and a
  focus-visible outline that stays legible on the tinted notice
- Restyle the legacy amber variant to match instead of only recoloring
  the old link text
- Skip the press scale under prefers-reduced-motion
Sender, Instasent, WP Post Creation and WP User Registration pointed at
renamed or placeholder doc pages. zendeskSupport, weDocs and wsms were
already referenced by their components but absent from the map, and
Webba Booking Calendar, MainWP, WordPress, MoreConvert Wishlist, Secure
Custom Fields, Heffl CRM and IvyForms had no tutorial link wiring at
all, so none of them showed a Documentation link.
Connections store app_slug as the UI display name, while $authConfig
carries a bare slug, so belongsToIntegration() reduced both to
alphanumerics and compared them. A display name with a second brand word
cannot reduce to the slug: "Brevo(Sendinblue)" never equals "sendinblue",
so CredentialInjector skipped injection and Brevo's refresh endpoints
answered "Requested parameter is empty" while a saved flow reported its
fields as missing.

$authConfig now takes an optional aliases list holding the stored names,
and the check accepts slug-or-alias. Kept it an explicit list rather than
a substring match, which would let one Zoho connection satisfy every
zoho* controller — the cross-app leak the check exists to stop.

Audited every action's slug against its frontend type; five integrations
needed aliases: Brevo(Sendinblue), Kit(ConvertKit), Sarbacane(Mailify),
Zoho Marketing Automation(Zoho Marketing Hub) and License Manager For
WooCommerce.
The row-action menu (.btcd-menu-list / .btcd-m-a) unrolls from the edge
its button sits on, and nothing else in the plugin did: the columns menu
flipped between display none and block, and the bulk-actions menu and the
AI popover were mounted and unmounted, so both simply appeared.

All three now share menu-book-closed/menu-book-open. The panels stay
mounted, hidden by visibility so they keep out of the tab order, and a
class toggle drives the reveal, which also gives them a close animation.
Each menu declares the width it opens to and the corner it rests at, and
passes the side its trigger hinges on — right for the two table menus,
left for the AI popover.

The original unrolls by animating width, which relays out the panel and
its contents on every frame. This reveals a full-size panel with a
clip-path inset instead, so the work is a paint rather than a layout; the
insets run negative so the drop shadow falls outside the clip and
survives. Height stays out of the transition entirely — it cannot
interpolate to auto, and collapsing it swallows the reveal on close,
which is why the original writes a measured pixel height inline and
leaves its own `height: 0` dead.

Verified in headless Chrome against the built CSS: the panels report an
identical width open and closed, so nothing reflows, and the captured
frames show each one uncovering from its hinge with the shadow intact.
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

The plugin-not-installed error interpolates the plugin name into a
translatable string with no translators comment, which WordPress Plugin
Check flags. Add one, and align the $event param annotation with the
rest of the block.
…y hooks

Plugin Check infers the PrefixAllGlobals prefix list by scanning the plugin's
own do_action/apply_filters names rather than using the plugin slug. Because
this plugin fires third-party hooks from its Actions, the inferred list fills
with names like 'academy/admin/course_complete' and never contains
'bit_integrations' -- so correctly prefixed functions and variables get
flagged as unprefixed.

- bitwpfi.php, ActionHook/Hooks.php, FallbackTrigger/Hooks.php and the failure
  notification template: ignore NonPrefixedFunctionFound/NonPrefixedVariableFound.
  All of these already carry the plugin slug as their prefix.
- BitCrmActionHelper: ignore NonPrefixedHooknameFound for the file. Every
  do_action there fires one of Bit CRM's own bit_crm/* hooks on its behalf,
  because the service methods it calls do not fire them themselves. The names
  must match Bit CRM's namespace for its listeners to react.

Verified with 'wp plugin check bit-integrations --checks=prefixing'. The only
remaining warning is InvalidPrefixPassed on bitwpfi.php line 1, which reports a
malformed entry in the scanner's own inferred list and cannot be suppressed.
@RishadAlam RishadAlam changed the title feat: webhook path variables, connection switching and security fixes Bump version v2.10.1 Jul 30, 2026
Return the cached filesystem before declaring the global so the hot path
skips the lookup, and collapse the assign-then-return blocks into single
assignment returns. Behavior is unchanged; the global $wp_filesystem is
still left untouched on the WP_Filesystem_Direct fallback path.
Assign the resolved filesystem in a branch and return it once at the end
instead of returning from each path.
@RishadAlam
RishadAlam merged commit 09a0b84 into main Jul 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants