diff --git a/.wordpress-org/banner-1544x500.gif b/.wordpress-org/banner-1544x500.gif deleted file mode 100644 index 880569c18..000000000 Binary files a/.wordpress-org/banner-1544x500.gif and /dev/null differ diff --git a/.wordpress-org/banner-1544x500.png b/.wordpress-org/banner-1544x500.png new file mode 100644 index 000000000..71a9df9fd Binary files /dev/null and b/.wordpress-org/banner-1544x500.png differ diff --git a/.wordpress-org/banner-772x250.gif b/.wordpress-org/banner-772x250.gif deleted file mode 100644 index bb047125a..000000000 Binary files a/.wordpress-org/banner-772x250.gif and /dev/null differ diff --git a/.wordpress-org/banner-772x250.png b/.wordpress-org/banner-772x250.png new file mode 100644 index 000000000..689a1a070 Binary files /dev/null and b/.wordpress-org/banner-772x250.png differ diff --git a/backend/Actions/BitCrm/BitCrmActionHelper.php b/backend/Actions/BitCrm/BitCrmActionHelper.php index a7db59fbc..1f265075f 100644 --- a/backend/Actions/BitCrm/BitCrmActionHelper.php +++ b/backend/Actions/BitCrm/BitCrmActionHelper.php @@ -8,6 +8,14 @@ exit; } +/** + * Every do_action() below fires one of Bit CRM's own `bit_crm/*` hooks on its + * behalf, because the service methods this helper calls do not fire them + * themselves. The names belong to Bit CRM's namespace and must match it exactly + * for its listeners to react, so they cannot carry this plugin's prefix. + * + * phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + */ final class BitCrmActionHelper { public static function createLead($fieldData) diff --git a/backend/Actions/ConvertKit/ConvertKitController.php b/backend/Actions/ConvertKit/ConvertKitController.php index 78b8e93c1..55705cd64 100644 --- a/backend/Actions/ConvertKit/ConvertKitController.php +++ b/backend/Actions/ConvertKit/ConvertKitController.php @@ -18,6 +18,8 @@ class ConvertKitController public static array $authConfig = [ 'authType' => AuthorizationType::API_KEY, 'slug' => 'convertkit', + // Connections store the UI's display name, which carries the Kit rebrand. + 'aliases' => ['Kit(ConvertKit)'], 'fields' => [ 'api_secret' => 'value', ], diff --git a/backend/Actions/CustomAction/CustomActionController.php b/backend/Actions/CustomAction/CustomActionController.php index 3f2c634cb..4d48eceef 100644 --- a/backend/Actions/CustomAction/CustomActionController.php +++ b/backend/Actions/CustomAction/CustomActionController.php @@ -32,9 +32,15 @@ public static function functionValidateHandler($data) public function execute($integrationData, $fieldValues) { - $funcFileLocation = $integrationData->flow_details->funcFileLocation; $integId = $integrationData->id; - $isExits = file_exists($funcFileLocation); + + // funcFileLocation arrives inside flow_details (caller-supplied JSON), so file_exists() + // alone would include any readable PHP on the box. Only ever run a file the plugin + // itself wrote into the custom-function directory. + $funcFileLocation = CustomFuncValidator::resolveCustomFunctionFile( + $integrationData->flow_details->funcFileLocation ?? '' + ); + $isExits = $funcFileLocation !== ''; $isSuccessfullyRun = true; $additionalData = null; diff --git a/backend/Actions/Dropbox/RecordApiHelper.php b/backend/Actions/Dropbox/RecordApiHelper.php index 49236c184..0709a00fd 100644 --- a/backend/Actions/Dropbox/RecordApiHelper.php +++ b/backend/Actions/Dropbox/RecordApiHelper.php @@ -3,6 +3,7 @@ namespace BitApps\Integrations\Actions\Dropbox; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\HttpHelper; use BitApps\Integrations\Log\LogHandler; use WP_Error; @@ -33,7 +34,7 @@ public function uploadFile($folder, $filePath) return new WP_Error(423, __('Can\'t open file!', 'bit-integrations')); } - $body = file_get_contents($safeFilePath); + $body = FileSystem::read($safeFilePath); if (!$body) { return new WP_Error(423, __('Can\'t open file!', 'bit-integrations')); diff --git a/backend/Actions/Encharge/RecordApiHelper.php b/backend/Actions/Encharge/RecordApiHelper.php index 3c597bc29..bc739e2fa 100644 --- a/backend/Actions/Encharge/RecordApiHelper.php +++ b/backend/Actions/Encharge/RecordApiHelper.php @@ -47,7 +47,6 @@ public function execute($fieldValues, $fieldMap, $tags) foreach ($fieldMap as $fieldKey => $fieldPair) { if (!empty($fieldPair->enChargeFields)) { - // echo $fieldPair->enChargeFields . ' ' . $fieldPair->formField; if ($fieldPair->formField === 'custom' && isset($fieldPair->customValue)) { $fieldData[$fieldPair->enChargeFields] = Common::replaceFieldWithValue($fieldPair->customValue, $fieldValues); } elseif (!\is_null($fieldValues[$fieldPair->formField])) { @@ -76,7 +75,7 @@ public function execute($fieldValues, $fieldMap, $tags) private function combineTagsWithExisting($tags, $email) { - $endpoint = $this->_endpoint . '?people[0][email]=' . urlencode($email); + $endpoint = $this->_endpoint . '?people[0][email]=' . rawurlencode($email); $response = HttpHelper::get($endpoint, null, $this->_defaultHeader); diff --git a/backend/Actions/Fabman/FabmanController.php b/backend/Actions/Fabman/FabmanController.php index bc593ed2b..c1d8835b2 100644 --- a/backend/Actions/Fabman/FabmanController.php +++ b/backend/Actions/Fabman/FabmanController.php @@ -156,7 +156,7 @@ private static function fetchMemberByEmail($apiKey, $email) 'Content-Type' => 'application/json' ]; - $apiEndpoint = 'https://fabman.io/api/v1/members?q=' . urlencode($email); + $apiEndpoint = 'https://fabman.io/api/v1/members?q=' . rawurlencode($email); $apiResponse = HttpHelper::get($apiEndpoint, null, $header); if (is_wp_error($apiResponse) || !\is_array($apiResponse) || empty($apiResponse)) { diff --git a/backend/Actions/Fabman/RecordApiHelper.php b/backend/Actions/Fabman/RecordApiHelper.php index 01adc5199..6ec012905 100644 --- a/backend/Actions/Fabman/RecordApiHelper.php +++ b/backend/Actions/Fabman/RecordApiHelper.php @@ -160,7 +160,7 @@ private function createMember($data) { unset($data['memberId']); $apiEndpoint = self::API_ENDPOINT . '/members'; - $apiResponse = HttpHelper::post($apiEndpoint, json_encode($data), $this->setHeaders()); + $apiResponse = HttpHelper::post($apiEndpoint, wp_json_encode($data), $this->setHeaders()); if (\is_wp_error($apiResponse)) { return $apiResponse; @@ -181,13 +181,13 @@ private function updateMember($data) return new WP_Error('MISSING_MEMBER_ID', __('The email provided did not match any existing Fabman member.', 'bit-integrations')); } - $response = Hooks::apply(Config::withPrefix('fabman_update_member'), false, json_encode($data), $this->setHeaders(), self::API_ENDPOINT, $this->memberId); + $response = Hooks::apply(Config::withPrefix('fabman_update_member'), false, wp_json_encode($data), $this->setHeaders(), self::API_ENDPOINT, $this->memberId); /** * @deprecated 2.7.8 Use `bit_integrations_fabman_update_member` filter instead. * @since 2.7.8 */ - $response = Hooks::apply('btcbi_fabman_update_member', $response, json_encode($data), $this->setHeaders(), self::API_ENDPOINT, $this->memberId); + $response = Hooks::apply('btcbi_fabman_update_member', $response, wp_json_encode($data), $this->setHeaders(), self::API_ENDPOINT, $this->memberId); return $this->handleFilterResponse($response); } @@ -212,13 +212,13 @@ private function deleteMember() private function createSpace($data) { unset($data['space']); - $response = Hooks::apply(Config::withPrefix('fabman_create_space'), false, json_encode($data), $this->setHeaders(), self::API_ENDPOINT); + $response = Hooks::apply(Config::withPrefix('fabman_create_space'), false, wp_json_encode($data), $this->setHeaders(), self::API_ENDPOINT); /** * @deprecated 2.7.8 Use `bit_integrations_fabman_create_space` filter instead. * @since 2.7.8 */ - $response = Hooks::apply('btcbi_fabman_create_space', $response, json_encode($data), $this->setHeaders(), self::API_ENDPOINT); + $response = Hooks::apply('btcbi_fabman_create_space', $response, wp_json_encode($data), $this->setHeaders(), self::API_ENDPOINT); return $this->handleFilterResponse($response); } @@ -236,13 +236,13 @@ private function updateSpace($data) $data['lockVersion'] = (int) $this->lockVersion; - $response = Hooks::apply(Config::withPrefix('fabman_update_space'), false, json_encode($data), $this->setHeaders(), self::API_ENDPOINT, $this->workspaceId); + $response = Hooks::apply(Config::withPrefix('fabman_update_space'), false, wp_json_encode($data), $this->setHeaders(), self::API_ENDPOINT, $this->workspaceId); /** * @deprecated 2.7.8 Use `bit_integrations_fabman_update_space` filter instead. * @since 2.7.8 */ - $response = Hooks::apply('btcbi_fabman_update_space', $response, json_encode($data), $this->setHeaders(), self::API_ENDPOINT, $this->workspaceId); + $response = Hooks::apply('btcbi_fabman_update_space', $response, wp_json_encode($data), $this->setHeaders(), self::API_ENDPOINT, $this->workspaceId); return $this->handleFilterResponse($response); } diff --git a/backend/Actions/GoogleDrive/RecordApiHelper.php b/backend/Actions/GoogleDrive/RecordApiHelper.php index ff374d702..c911928ba 100644 --- a/backend/Actions/GoogleDrive/RecordApiHelper.php +++ b/backend/Actions/GoogleDrive/RecordApiHelper.php @@ -3,6 +3,7 @@ namespace BitApps\Integrations\Actions\GoogleDrive; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\HttpHelper; use BitApps\Integrations\Log\LogHandler; @@ -95,7 +96,7 @@ protected function getBody($folder, $filePath, $boundary) $body .= '{"name": "' . basename($filePath) . '", "parents": ["' . $folder . '"]}' . "\r\n"; $body .= '--' . $boundary . "\r\n"; $body .= "Content-Type: application/octet-stream\r\n\r\n"; - $body .= file_get_contents($filePath) . "\r\n"; + $body .= FileSystem::read($filePath) . "\r\n"; $body .= '--' . $boundary . "--\r\n"; return $body; diff --git a/backend/Actions/LMFWC/LMFWCController.php b/backend/Actions/LMFWC/LMFWCController.php index 8fb9d7ab2..aad355e99 100644 --- a/backend/Actions/LMFWC/LMFWCController.php +++ b/backend/Actions/LMFWC/LMFWCController.php @@ -18,6 +18,8 @@ class LMFWCController public static array $authConfig = [ 'authType' => AuthorizationType::API_KEY, 'slug' => 'lmfwc', + // Connections store the UI's display name, which spells the plugin out. + 'aliases' => ['License Manager For WooCommerce'], 'fields' => [ 'api_key' => 'value', 'api_secret' => 'api_secret', diff --git a/backend/Actions/Line/RecordApiHelper.php b/backend/Actions/Line/RecordApiHelper.php index c0b07889f..89170601e 100644 --- a/backend/Actions/Line/RecordApiHelper.php +++ b/backend/Actions/Line/RecordApiHelper.php @@ -37,18 +37,18 @@ public function execute($integrationDetails, $fieldValues) switch ($type) { case 'sendReplyMessage': $data['replyToken'] = $integrationDetails->replyToken ?? ''; - $response = $this->sendReplyMessage(json_encode($data)); + $response = $this->sendReplyMessage(wp_json_encode($data)); break; case 'sendBroadcastMessage': - $response = $this->sendBroadcastMessage(json_encode($data)); + $response = $this->sendBroadcastMessage(wp_json_encode($data)); break; default: $data['to'] = $integrationDetails->recipientId ?? ''; - $response = $this->sendPushMessage(json_encode($data)); + $response = $this->sendPushMessage(wp_json_encode($data)); $response = HttpHelper::$responseCode === 200 ? 'Push message sent successfully' : 'Failed'; } diff --git a/backend/Actions/Mail/MailController.php b/backend/Actions/Mail/MailController.php index b060855b1..df3eff561 100644 --- a/backend/Actions/Mail/MailController.php +++ b/backend/Actions/Mail/MailController.php @@ -78,33 +78,56 @@ public static function filterMailContentType() return 'text/html; charset=UTF-8'; } + /** + * Resolve mapped address values and keep only the ones that are real addresses. + * + * The scalar branch used to return the interpolated value unchecked, so a submitted + * form field became the recipient/header verbatim. Both branches now apply the same + * is_email() filter, and anything that fails it is dropped rather than passed on. + * + * @param array|string $emailAddresses + * @param array $fieldValues + * + * @return array + */ public function validateAddresses($emailAddresses, $fieldValues) { - if (!\is_array($emailAddresses)) { - return [Common::replaceFieldWithValue($emailAddresses, $fieldValues)]; - } - foreach ($emailAddresses as $key => $email) { + $candidates = \is_array($emailAddresses) ? $emailAddresses : [$emailAddresses]; + $valid = []; + + foreach ($candidates as $email) { + if (!\is_scalar($email)) { + continue; + } + + $email = (string) $email; + if (!is_email($email)) { $email = Common::replaceFieldWithValue($email, $fieldValues); } - if (is_email($email)) { - $emailAddresses[$key] = $email; + + // A single mapped field may resolve to a comma-separated list. + foreach (explode(',', (string) $email) as $candidate) { + $candidate = sanitize_email(trim($candidate)); + + if ($candidate !== '' && is_email($candidate)) { + $valid[] = $candidate; + } } } - return $emailAddresses; + return array_values(array_unique($valid)); } public function processHeader($type, $address, $fields) { $headers = []; - $addresses = $this->validateAddresses($address, $fields); - if (\is_array($addresses)) { - foreach ($addresses as $address) { - $headers[] = "{$type}: " . explode('@', $address)[0] . '<' . sanitize_email($address) . '>'; - } - } else { - $headers[] = "{$type}: " . explode('@', $addresses)[0] . '<' . sanitize_email($addresses) . '>'; + + foreach ($this->validateAddresses($address, $fields) as $validAddress) { + // The local part becomes the header display name, so it must be sanitized too — + // it is submitter-controlled and lands in a raw header string. + $displayName = sanitize_text_field(explode('@', $validAddress)[0]); + $headers[] = "{$type}: {$displayName}<{$validAddress}>"; } return $headers; diff --git a/backend/Actions/MailRelay/RecordApiHelper.php b/backend/Actions/MailRelay/RecordApiHelper.php index f000b8a46..2659b5ad4 100644 --- a/backend/Actions/MailRelay/RecordApiHelper.php +++ b/backend/Actions/MailRelay/RecordApiHelper.php @@ -122,7 +122,7 @@ public function execute($selectedGroups, $fieldValues, $fieldMap, $status) public function isExist($baseUrl, $email) { $queryEndpoints = $baseUrl . 'subscribers?q%5Bemail_eq%5D='; - $encodedEmail = urlencode($email); + $encodedEmail = rawurlencode($email); $apiEndpoints = $queryEndpoints . $encodedEmail; $response = HttpHelper::get($apiEndpoints, null, $this->_defaultHeader); diff --git a/backend/Actions/Mailify/MailifyController.php b/backend/Actions/Mailify/MailifyController.php index 86d692eb5..d39044397 100644 --- a/backend/Actions/Mailify/MailifyController.php +++ b/backend/Actions/Mailify/MailifyController.php @@ -11,6 +11,8 @@ class MailifyController public static array $authConfig = [ 'authType' => AuthorizationType::BASIC_AUTH, 'slug' => 'mailify', + // Connections store the UI's display name, which carries the Sarbacane rebrand. + 'aliases' => ['Sarbacane(Mailify)'], 'fields' => [ 'account_id' => 'username', 'api_key' => 'password', diff --git a/backend/Actions/Mailjet/RecordApiHelper.php b/backend/Actions/Mailjet/RecordApiHelper.php index 82f976cbd..72bc29f9e 100644 --- a/backend/Actions/Mailjet/RecordApiHelper.php +++ b/backend/Actions/Mailjet/RecordApiHelper.php @@ -113,7 +113,7 @@ private function jobMonitoring($response) private function isExist($email) { - $encodedEmail = urlencode($email); + $encodedEmail = rawurlencode($email); $apiEndpoint = 'https://api.mailjet.com/v3/REST/contact/' . $encodedEmail; $response = HttpHelper::get($apiEndpoint, null, $this->_defaultHeader); diff --git a/backend/Actions/OneDrive/RecordApiHelper.php b/backend/Actions/OneDrive/RecordApiHelper.php index d3f3f0972..f1b8d245f 100644 --- a/backend/Actions/OneDrive/RecordApiHelper.php +++ b/backend/Actions/OneDrive/RecordApiHelper.php @@ -3,6 +3,7 @@ namespace BitApps\Integrations\Actions\OneDrive; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\HttpHelper; use BitApps\Integrations\Log\LogHandler; @@ -46,7 +47,7 @@ public function uploadFile($folder, $file, $folderId, $parentId) return HttpHelper::post( $apiEndpoint, - file_get_contents($filePath), + FileSystem::read($filePath), $headers ); } diff --git a/backend/Actions/Salesforce/SalesforceController.php b/backend/Actions/Salesforce/SalesforceController.php index 005bca3d4..83af35f3c 100644 --- a/backend/Actions/Salesforce/SalesforceController.php +++ b/backend/Actions/Salesforce/SalesforceController.php @@ -306,7 +306,7 @@ public static function selesforceUserList($params) $response = self::refreshTokenDetails($params); $tokenDetails = $response['tokenDetails']; - $apiEndpoint = "{$tokenDetails->instance_url}/services/data/v37.0/query/?q=" . urlencode('SELECT Id, Name FROM User'); + $apiEndpoint = "{$tokenDetails->instance_url}/services/data/v37.0/query/?q=" . rawurlencode('SELECT Id, Name FROM User'); $apiResponse = HttpHelper::get($apiEndpoint, null, self::setHeaders($tokenDetails->access_token)); diff --git a/backend/Actions/SendinBlue/SendinBlueController.php b/backend/Actions/SendinBlue/SendinBlueController.php index 95225d236..ede0d77af 100644 --- a/backend/Actions/SendinBlue/SendinBlueController.php +++ b/backend/Actions/SendinBlue/SendinBlueController.php @@ -20,6 +20,8 @@ class SendinBlueController public static array $authConfig = [ 'authType' => AuthorizationType::API_KEY, 'slug' => 'sendinblue', + // Connections store the UI's display name, which carries the Brevo rebrand. + 'aliases' => ['Brevo(Sendinblue)'], 'fields' => [ 'api_key' => 'value', ], diff --git a/backend/Actions/WebHooks/WebHooksController.php b/backend/Actions/WebHooks/WebHooksController.php index 2fcdaaff4..e03ea5128 100644 --- a/backend/Actions/WebHooks/WebHooksController.php +++ b/backend/Actions/WebHooks/WebHooksController.php @@ -18,57 +18,129 @@ class WebHooksController public static function testWebhook($webhookDetails) { $data['flow_details'] = $webhookDetails->hookDetails; - $response = self::execute((object) $data, []); + $response = self::execute((object) $data, [], true); + $responseCode = HttpHelper::$responseCode; + if (is_wp_error($response)) { wp_send_json_error( empty($response) ? 'Unknown Error Occurred' : $response->get_error_message(), 400 ); } - wp_send_json_success(__('Test webhook executed succcessfully', 'bit-integrations'), 200); + + if (self::hasFailed($response, $responseCode)) { + wp_send_json_error( + sprintf( + /* translators: %s: http status code returned by the webhook url */ + __('Webhook responded with status %s', 'bit-integrations'), + $responseCode + ), + 400 + ); + } + + wp_send_json_success( + empty($responseCode) + ? __('Test webhook executed successfully', 'bit-integrations') + : sprintf( + /* translators: %s: http status code returned by the webhook url */ + __('Test webhook executed successfully (status %s)', 'bit-integrations'), + $responseCode + ), + 200 + ); } - public static function execute($integrationDetails, $fieldValues) + public static function execute($integrationDetails, $fieldValues, $isTest = false) { $fieldValues = self::iterate($fieldValues); $details = $integrationDetails->flow_details; $type = $details->type; $integId = isset($integrationDetails->id) ? $integrationDetails->id : ''; $method = isset($details->method) ? $details->method : 'get'; - $url = isset($details->url) ? self::urlParserWrapper($details->url, $fieldValues) : false; + $pathParams = isset($details->pathParams) ? $details->pathParams : []; + $url = isset($details->url) ? self::urlParserWrapper($details->url, $fieldValues, $pathParams, $isTest) : false; + if (empty($url)) { + $url = new \WP_Error('bit-integrations-webhook-url', __('Webhook url is empty', 'bit-integrations')); + } + if (is_wp_error($url)) { + LogHandler::save($integId, wp_json_encode(['type' => $type, 'type_name' => $type]), 'error', $url); + + return $url; + } $boundary = wp_generate_password(24); $payload = self::processPayload($details, $fieldValues, $boundary); $headers = self::processHeaders($details, $fieldValues, $boundary); - if ($url) { - switch (strtoupper($method)) { - case 'GET': - $response = HttpHelper::get($url, [], $headers); - break; + switch (strtoupper($method)) { + case 'GET': + $response = HttpHelper::get($url, [], $headers); - case 'POST': - $response = HttpHelper::post($url, $payload, $headers); + break; - break; + case 'POST': + $response = HttpHelper::post($url, $payload, $headers); - default: - $response = HttpHelper::request($url, $method, $payload, $headers); + break; - break; - } + default: + $response = HttpHelper::request($url, $method, $payload, $headers); + + break; } - if (is_wp_error($response) || !empty($response->error)) { - LogHandler::save($integId, wp_json_encode(['type' => $type, 'type_name' => $type]), 'error', $response); + $responseCode = HttpHelper::$responseCode; + + if (self::hasFailed($response, $responseCode)) { + LogHandler::save($integId, wp_json_encode(['type' => $type, 'type_name' => $type]), 'error', wp_json_encode(['status' => $responseCode, 'response' => $response])); } else { - // file_put_contents(__DIR__ . '/bit-integrations-webhook-response.json', wp_json_encode($response)); LogHandler::save($integId, wp_json_encode(['type' => $type, 'type_name' => $type]), 'success', !empty($response) ? wp_json_encode($response) : 'Successfully executed webhook'); } return $response; } - private static function urlParserWrapper($url, $fieldValues = []) + /** + * Decides whether a webhook run failed. + * + * A remote that answers 404 or 500 still returns a decoded body, so the status + * code is the only reliable signal. An unknown code (transport level failure + * already covered by is_wp_error) is not treated as a failure on its own. + * + * @param mixed $response + * @param null|int $responseCode + * + * @return bool + */ + private static function hasFailed($response, $responseCode) + { + if (is_wp_error($response)) { + return true; + } + + if (\is_object($response) && !empty($response->error)) { + return true; + } + + if (empty($responseCode) || !is_numeric($responseCode)) { + return false; + } + + return $responseCode < 200 || $responseCode >= 300; + } + + /** + * Rebuilds the webhook url, resolving smart tags in the query string and in + * the url path (dynamic route parameters). + * + * @param string $url + * @param array $fieldValues Trigger data + * @param array|object $pathParams Placeholder => value map, e.g. [{key: 'id', value: '${post_id}'}] + * @param bool $isTest Test Webhook run, no trigger data available + * + * @return string|WP_Error + */ + private static function urlParserWrapper($url, $fieldValues = [], $pathParams = [], $isTest = false) { if (empty($url)) { return $url; @@ -84,6 +156,12 @@ private static function urlParserWrapper($url, $fieldValues = []) $Query = isset($parsedURL['query']) ? $parsedURL['query'] : null; $Pass = ($Pass || $Usr) ? "{$Pass}@" : null; + // resolved after parsing, so a dynamic value can never rewrite scheme/host/port + $Path = self::resolvePathParams($Path, $pathParams, $fieldValues, $isTest); + if (is_wp_error($Path)) { + return $Path; + } + $cleanURL = "{$Scheme}{$Usr}{$Pass}{$Host}{$Port}{$Path}"; $params = []; foreach (explode('&', (string) $Query) as $keyValue) { @@ -107,11 +185,94 @@ private static function urlParserWrapper($url, $fieldValues = []) $params = Common::replaceFieldWithValue($params, $fieldValues); $params = http_build_query($params); - $cleanURL .= "?{$params}"; + if ('' !== $params) { + $cleanURL .= "?{$params}"; + } return $cleanURL; } + /** + * Replaces dynamic route parameters in the url path. + * + * Two notations are supported: + * - `{name}` mapped to a value through the `pathParams` config + * - `${field}` smart tag written inline in the path + * + * Resolved values are raw url encoded, so they always stay inside the single + * path segment they were written in. + * + * @param null|string $path + * @param array|object $pathParams + * @param array $fieldValues + * @param bool $isTest + * + * @return null|string|WP_Error + */ + private static function resolvePathParams($path, $pathParams, $fieldValues, $isTest = false) + { + if (empty($path) || false === strpos($path, '{')) { + return $path; + } + + $mapping = []; + foreach ((array) $pathParams as $param) { + $param = (object) $param; + if (!isset($param->key) || '' === trim((string) $param->key)) { + continue; + } + $mapping[trim((string) $param->key)] = isset($param->value) ? $param->value : ''; + } + + $emptyTokens = []; + $resolvedPath = preg_replace_callback( + '/\$\{\w[^ ${}]*\}|\{[^{}\s\/?#]+\}/', + function ($matches) use ($mapping, $fieldValues, $isTest, &$emptyTokens) { + $token = $matches[0]; + + if (0 === strpos($token, '${')) { + $value = Common::replaceFieldWithValue($token, $fieldValues); + } else { + $name = substr($token, 1, -1); + if (!\array_key_exists($name, $mapping)) { + return $token; // not mapped, keep the literal placeholder + } + $value = Common::replaceFieldWithValue($mapping[$name], $fieldValues); + } + + if (\is_array($value) || \is_object($value)) { + $value = wp_json_encode($value); + } + $value = trim((string) $value); + + if ('' === $value) { + if ($isTest) { + return $token; // no trigger data while testing, keep it visible + } + $emptyTokens[$token] = true; + + return $token; + } + + return rawurlencode($value); + }, + $path + ); + + if (!empty($emptyTokens)) { + return new \WP_Error( + 'bit-integrations-webhook-path-param', + sprintf( + /* translators: %s: comma separated url path variables, e.g. {id}, {slug} */ + __('Url path variable %s has no value, webhook request skipped', 'bit-integrations'), + implode(', ', array_keys($emptyTokens)) + ) + ); + } + + return null === $resolvedPath ? $path : $resolvedPath; + } + private static function processHeaders($details, $fieldValues, $boundary = null) { $headers = isset($details->headers) ? self::processKeyValue((array) $details->headers, $fieldValues) : []; diff --git a/backend/Actions/WooCommerce/RecordApiHelper.php b/backend/Actions/WooCommerce/RecordApiHelper.php index 466eddb81..c3a47c646 100644 --- a/backend/Actions/WooCommerce/RecordApiHelper.php +++ b/backend/Actions/WooCommerce/RecordApiHelper.php @@ -7,6 +7,7 @@ namespace BitApps\Integrations\Actions\WooCommerce; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Log\LogHandler; use WC_Product_Download; use WP_Error; @@ -75,7 +76,6 @@ public function createCustomer($fieldMapCustomer, $required, $module, $fieldValu } return $user_id; - // } } public function findCustomer($fieldMapCustomer, $required, $module, $fieldValues) @@ -639,7 +639,7 @@ public function upload_attachment($product_id, $url) $filename = basename(wp_parse_url($url, PHP_URL_PATH)); $tmp = wp_tempnam($filename); - if (!$tmp || file_put_contents($tmp, $image_data) === false) { + if (!$tmp || !FileSystem::write($tmp, $image_data)) { return false; } diff --git a/backend/Actions/ZohoBigin/FilesApiHelper.php b/backend/Actions/ZohoBigin/FilesApiHelper.php index f5a913366..ee651afa5 100644 --- a/backend/Actions/ZohoBigin/FilesApiHelper.php +++ b/backend/Actions/ZohoBigin/FilesApiHelper.php @@ -7,6 +7,7 @@ namespace BitApps\Integrations\Actions\ZohoBigin; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\HttpHelper; /** @@ -63,7 +64,7 @@ public function uploadFiles($files, $module, $recordID, $isPhoto = null) $payload .= 'Content-Disposition: form-data; name="' . 'file' . '"; filename="' . basename("{$fileName}") . '"' . "\r\n"; $payload .= "\r\n"; - $payload .= file_get_contents($safeFile); + $payload .= FileSystem::read($safeFile); $payload .= "\r\n"; } } @@ -73,7 +74,7 @@ public function uploadFiles($files, $module, $recordID, $isPhoto = null) $payload .= 'Content-Disposition: form-data; name="' . 'file' . '"; filename="' . basename("{$files}") . '"' . "\r\n"; $payload .= "\r\n"; - $payload .= file_get_contents($safeFiles); + $payload .= FileSystem::read($safeFiles); $payload .= "\r\n"; } if (empty($payload)) { diff --git a/backend/Actions/ZohoCRM/FilesApiHelper.php b/backend/Actions/ZohoCRM/FilesApiHelper.php index 921a45a90..0127e7de1 100644 --- a/backend/Actions/ZohoCRM/FilesApiHelper.php +++ b/backend/Actions/ZohoCRM/FilesApiHelper.php @@ -7,6 +7,7 @@ namespace BitApps\Integrations\Actions\ZohoCRM; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\HttpHelper; use BitApps\Integrations\Log\LogHandler; @@ -113,7 +114,7 @@ public function preparePayload($file) if ($safeFilePath === '') { return ''; } - $payload .= file_get_contents($safeFilePath); + $payload .= FileSystem::read($safeFilePath); } $payload .= "\r\n"; diff --git a/backend/Actions/ZohoCampaigns/RecordApiHelper.php b/backend/Actions/ZohoCampaigns/RecordApiHelper.php index 347d1b9f8..c51e69632 100644 --- a/backend/Actions/ZohoCampaigns/RecordApiHelper.php +++ b/backend/Actions/ZohoCampaigns/RecordApiHelper.php @@ -31,7 +31,7 @@ public function __construct($tokenDetails, $integId) public function insertRecord($list, $dataCenter, $data) { - $insertRecordEndpoint = "https://campaigns.zoho.{$dataCenter}/api/v1.1/json/listsubscribe?resfmt=JSON&listkey={$list}&contactinfo=" . urlencode($data); + $insertRecordEndpoint = "https://campaigns.zoho.{$dataCenter}/api/v1.1/json/listsubscribe?resfmt=JSON&listkey={$list}&contactinfo=" . rawurlencode($data); return HttpHelper::post($insertRecordEndpoint, null, $this->_defaultHeader); } diff --git a/backend/Actions/ZohoDesk/FilesApiHelper.php b/backend/Actions/ZohoDesk/FilesApiHelper.php index 32749a008..8398224d6 100644 --- a/backend/Actions/ZohoDesk/FilesApiHelper.php +++ b/backend/Actions/ZohoDesk/FilesApiHelper.php @@ -7,6 +7,7 @@ namespace BitApps\Integrations\Actions\ZohoDesk; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\HttpHelper; /** @@ -62,7 +63,7 @@ public function uploadFiles($files, $ticketId, $dataCenter) $payload .= 'Content-Disposition: form-data; name="' . 'file' . '"; filename="' . basename("{$fileName}") . '"' . "\r\n"; $payload .= "\r\n"; - $payload .= file_get_contents($safeFile); + $payload .= FileSystem::read($safeFile); $payload .= "\r\n"; $payload .= '--' . $this->_payloadBoundary . '--'; } @@ -76,7 +77,7 @@ public function uploadFiles($files, $ticketId, $dataCenter) $payload .= 'Content-Disposition: form-data; name="' . 'file' . '"; filename="' . basename("{$files}") . '"' . "\r\n"; $payload .= "\r\n"; - $payload .= file_get_contents($safeFiles); + $payload .= FileSystem::read($safeFiles); $payload .= "\r\n"; } if (empty($payload)) { diff --git a/backend/Actions/ZohoMarketingHub/RecordApiHelper.php b/backend/Actions/ZohoMarketingHub/RecordApiHelper.php index 6af41059f..2332c1e3f 100644 --- a/backend/Actions/ZohoMarketingHub/RecordApiHelper.php +++ b/backend/Actions/ZohoMarketingHub/RecordApiHelper.php @@ -31,7 +31,7 @@ public function __construct($tokenDetails, $integId) public function insertRecord($list, $dataCenter, $data) { - $insertRecordEndpoint = "https://marketinghub.zoho.{$dataCenter}/api/v1/json/listsubscribe?resfmt=JSON&listkey={$list}&leadinfo=" . urlencode($data); + $insertRecordEndpoint = "https://marketinghub.zoho.{$dataCenter}/api/v1/json/listsubscribe?resfmt=JSON&listkey={$list}&leadinfo=" . rawurlencode($data); return HttpHelper::post($insertRecordEndpoint, null, $this->_defaultHeader); } diff --git a/backend/Actions/ZohoMarketingHub/ZohoMarketingHubController.php b/backend/Actions/ZohoMarketingHub/ZohoMarketingHubController.php index 993e812db..d714576e7 100644 --- a/backend/Actions/ZohoMarketingHub/ZohoMarketingHubController.php +++ b/backend/Actions/ZohoMarketingHub/ZohoMarketingHubController.php @@ -19,6 +19,8 @@ class ZohoMarketingHubController public static array $authConfig = [ 'authType' => AuthorizationType::OAUTH2, 'slug' => 'zohomarketinghub', + // Connections store the UI's display name, which carries the Zoho rename. + 'aliases' => ['Zoho Marketing Automation(Zoho Marketing Hub)'], 'fields' => [ 'dataCenter' => 'dataCenter', 'clientId' => 'client_id', diff --git a/backend/Actions/ZohoRecruit/FilesApiHelper.php b/backend/Actions/ZohoRecruit/FilesApiHelper.php index c2fc26b08..d90719b6a 100644 --- a/backend/Actions/ZohoRecruit/FilesApiHelper.php +++ b/backend/Actions/ZohoRecruit/FilesApiHelper.php @@ -7,6 +7,7 @@ namespace BitApps\Integrations\Actions\ZohoRecruit; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\HttpHelper; /** @@ -62,7 +63,7 @@ public function uploadFiles($files, $recordID, $zohoField) $payload .= 'Content-Disposition: form-data; name="' . 'content' . '"; filename="' . basename("{$fileName}") . '"' . "\r\n"; $payload .= "\r\n"; - $payload .= file_get_contents($safeFile); + $payload .= FileSystem::read($safeFile); $payload .= "\r\n"; } } @@ -72,7 +73,7 @@ public function uploadFiles($files, $recordID, $zohoField) $payload .= 'Content-Disposition: form-data; name="' . 'content' . '"; filename="' . basename("{$files}") . '"' . "\r\n"; $payload .= "\r\n"; - $payload .= file_get_contents($safeFiles); + $payload .= FileSystem::read($safeFiles); $payload .= "\r\n"; } if (empty($payload)) { diff --git a/backend/Admin/Admin_Bar.php b/backend/Admin/Admin_Bar.php index d70bda6cc..4f270e663 100644 --- a/backend/Admin/Admin_Bar.php +++ b/backend/Admin/Admin_Bar.php @@ -8,6 +8,7 @@ use BitApps\Integrations\Config; use BitApps\Integrations\Core\Util\Capabilities; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\Hooks; /** @@ -39,14 +40,16 @@ public function AdminMenu() '), 30); - } - $submenu['bit-integrations'] = [ - [__('All Integrations', 'bit-integrations'), $capability, 'admin.php?page=bit-integrations#/'], - [__('Connections', 'bit-integrations'), $capability, 'admin.php?page=bit-integrations#/connections'], - [__('Doc & Support', 'bit-integrations'), $capability, 'admin.php?page=bit-integrations#/doc-support'], - [__('Settings', 'bit-integrations'), $capability, 'admin.php?page=bit-integrations#/app-settings'], - ]; + // Written directly rather than via add_submenu_page() so the auto-generated + // first entry duplicating the parent is replaced instead of appended to. + $submenu['bit-integrations'] = [ + [__('All Integrations', 'bit-integrations'), $capability, 'admin.php?page=bit-integrations#/'], + [__('Connections', 'bit-integrations'), $capability, 'admin.php?page=bit-integrations#/connections'], + [__('Doc & Support', 'bit-integrations'), $capability, 'admin.php?page=bit-integrations#/doc-support'], + [__('Settings', 'bit-integrations'), $capability, 'admin.php?page=bit-integrations#/app-settings'], + ]; + } } /** @@ -85,8 +88,7 @@ public function AdminAssets($current_screen) $manifestPath = Config::get('BASEDIR') . 'assets/.vite/manifest.json'; if (file_exists($manifestPath)) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $manifest = json_decode(file_get_contents($manifestPath), true); + $manifest = json_decode(FileSystem::read($manifestPath), true); if (!empty($manifest['main.jsx']['css'])) { foreach ($manifest['main.jsx']['css'] as $index => $cssFile) { wp_enqueue_style( diff --git a/backend/Config.php b/backend/Config.php index d9c0d5462..b4d46c201 100644 --- a/backend/Config.php +++ b/backend/Config.php @@ -5,6 +5,7 @@ namespace BitApps\Integrations; use BitApps\Integrations\Core\Util\DateTimeHelper; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\Hooks; if (!defined('ABSPATH')) { @@ -22,7 +23,7 @@ class Config public const VAR_PREFIX = 'bit_integrations_'; - public const VERSION = '2.10.0'; + public const VERSION = '2.10.1'; public const DB_VERSION = '1.2'; @@ -179,7 +180,7 @@ public static function getDevUrl() public static function getDevPort() { - return self::isDev() ? file_get_contents(Config::get('BASEDIR') . '/.port') : null; + return self::isDev() ? FileSystem::read(Config::get('BASEDIR') . '/.port') : null; } /** @@ -278,69 +279,4 @@ private static function pluginPageLinks() ], ]; } - - /** - * Provides menus for wordpress admin sidebar. - * should return an array of menus with the following structure: - * [ - * 'type' => menu | submenu, - * 'name' => 'Name of menu will shown in sidebar', - * 'capability' => 'capability required to access menu', - * 'slug' => 'slug of menu after ?page=',. - * - * 'title' => 'page title will be shown in browser title if type is menu', - * 'callback' => 'function to call when menu is clicked', - * 'icon' => 'icon to display in menu if menu type is menu', - * 'position' => 'position of menu in sidebar if menu type is menu', - * - * 'parent' => 'parent slug if submenu' - * ] - * - * @return array - */ - // private static function sideBarMenu() - // { - // $adminViews = new Layout(); - - // return [ - // 'Home' => [ - // 'type' => 'menu', - // 'title' => __('Bit Integrations', 'bit-integrations'), - // 'name' => __('Bit Integrations', 'bit-integrations'), - // 'capability' => 'manage_options', - // 'slug' => self::SLUG, - // 'callback' => [$adminViews, 'body'], - // 'icon' => 'dashicons-admin-home', - // 'position' => '20', - // ], - // 'Dashboard' => [ - // 'parent' => self::SLUG, - // 'type' => 'submenu', - // 'name' => 'Dashboard', - // 'capability' => 'manage_options', - // 'slug' => self::SLUG . '#/', - // ], - // 'All Flows' => [ - // 'parent' => self::SLUG, - // 'type' => 'submenu', - // 'name' => 'Flows', - // 'capability' => 'manage_options', - // 'slug' => self::SLUG . '#/flows', - // ], - // 'Connections' => [ - // 'parent' => self::SLUG, - // 'type' => 'submenu', - // 'name' => 'Connections', - // 'capability' => 'manage_options', - // 'slug' => self::SLUG . '#/connections', - // ], - // 'Webhooks' => [ - // 'parent' => self::SLUG, - // 'type' => 'submenu', - // 'name' => 'Webhooks', - // 'capability' => 'manage_options', - // 'slug' => self::SLUG . '#/webhooks', - // ], - // ]; - // } } diff --git a/backend/Core/Hooks/HookService.php b/backend/Core/Hooks/HookService.php index 233eff9af..3352e4b05 100644 --- a/backend/Core/Hooks/HookService.php +++ b/backend/Core/Hooks/HookService.php @@ -7,6 +7,7 @@ use BitApps\Integrations\Admin\AdminAjax; use BitApps\Integrations\Core\Util\Hooks; use BitApps\Integrations\Core\Util\Request; +use BitApps\Integrations\Core\Util\Route; use BitApps\Integrations\Core\Util\StoreInCache; class HookService @@ -124,14 +125,24 @@ private function loadTriggersRoutes() { $task_dir = Config::get('BACKEND_DIR') . DIRECTORY_SEPARATOR . 'Triggers'; $dirs = new FilesystemIterator($task_dir); - foreach ($dirs as $dirInfo) { - if ($dirInfo->isDir()) { - $task_name = basename($dirInfo); - $task_path = $task_dir . DIRECTORY_SEPARATOR . $task_name . DIRECTORY_SEPARATOR; - if (is_readable($task_path . 'Routes.php') && Request::Check('ajax') && Request::Check('admin')) { - include $task_path . 'Routes.php'; + + // Trigger-owned routes fetch forms/fields and write test data without checking + // capabilities themselves, so they get the stricter baseline. Reset afterwards so + // it never leaks onto routes registered later in the request. + Route::defaultAccess('write'); + + try { + foreach ($dirs as $dirInfo) { + if ($dirInfo->isDir()) { + $task_name = basename($dirInfo); + $task_path = $task_dir . DIRECTORY_SEPARATOR . $task_name . DIRECTORY_SEPARATOR; + if (is_readable($task_path . 'Routes.php') && Request::Check('ajax') && Request::Check('admin')) { + include $task_path . 'Routes.php'; + } } } + } finally { + Route::defaultAccess('any'); } } @@ -139,17 +150,26 @@ private function _includeActionTaskHooks($task_name) { $task_dir = Config::get('BACKEND_DIR') . DIRECTORY_SEPARATOR . $task_name; $dirs = new FilesystemIterator($task_dir); - foreach ($dirs as $dirInfo) { - if ($dirInfo->isDir()) { - $task_name = basename($dirInfo); - $task_path = $task_dir . DIRECTORY_SEPARATOR . $task_name . DIRECTORY_SEPARATOR; - if (is_readable($task_path . 'Routes.php') && Request::Check('ajax') && Request::Check('admin')) { - include $task_path . 'Routes.php'; - } - if (is_readable($task_path . 'Hooks.php')) { - include $task_path . 'Hooks.php'; + + // Action-owned routes authorize credentials and call third-party APIs with no + // capability check of their own — a read-only role must not reach them. + Route::defaultAccess('write'); + + try { + foreach ($dirs as $dirInfo) { + if ($dirInfo->isDir()) { + $task_name = basename($dirInfo); + $task_path = $task_dir . DIRECTORY_SEPARATOR . $task_name . DIRECTORY_SEPARATOR; + if (is_readable($task_path . 'Routes.php') && Request::Check('ajax') && Request::Check('admin')) { + include $task_path . 'Routes.php'; + } + if (is_readable($task_path . 'Hooks.php')) { + include $task_path . 'Hooks.php'; + } } } + } finally { + Route::defaultAccess('any'); } } } diff --git a/backend/Core/Util/Capabilities.php b/backend/Core/Util/Capabilities.php index 033538fd7..608ed5437 100644 --- a/backend/Core/Util/Capabilities.php +++ b/backend/Core/Util/Capabilities.php @@ -20,6 +20,18 @@ final class Capabilities 'delete_integrations', ]; + /** + * The subset of the above that implies authority to change something. + * + * @var string[] + */ + private const INTEGRATION_WRITE_CAPABILITIES = [ + 'manage_integrations', + 'create_integrations', + 'edit_integrations', + 'delete_integrations', + ]; + public static function Check($cap, ...$args) { return current_user_can($cap, ...$args); @@ -39,12 +51,37 @@ public static function Filter($cap, $default = 'manage_options') * @return bool */ public static function hasIntegrationAccess() + { + return static::holdsAnyOf(self::INTEGRATION_CAPABILITIES); + } + + /** + * Whether the current user may change integration state, as opposed to only viewing it. + * + * `view_integrations` is deliberately absent. Integration-owned AJAX routes (per-action + * and per-trigger `Routes.php`) authorize credentials, hit third-party APIs and mutate + * connection config, and those controllers add no capability check of their own — so a + * read-only role must not reach them just by holding one plugin capability. + * + * @return bool + */ + public static function hasIntegrationWriteAccess() + { + return static::holdsAnyOf(self::INTEGRATION_WRITE_CAPABILITIES); + } + + /** + * @param string[] $capabilities Unprefixed plugin capabilities + * + * @return bool + */ + private static function holdsAnyOf(array $capabilities) { if (static::Check('manage_options')) { return true; } - foreach (self::INTEGRATION_CAPABILITIES as $capability) { + foreach ($capabilities as $capability) { if (static::Check(Config::withPrefix($capability))) { return true; } diff --git a/backend/Core/Util/CredentialInjector.php b/backend/Core/Util/CredentialInjector.php index 3472e7339..43bc0335e 100644 --- a/backend/Core/Util/CredentialInjector.php +++ b/backend/Core/Util/CredentialInjector.php @@ -47,7 +47,7 @@ public static function inject(object $target, string $controllerClass): void // controller asking for it: otherwise pointing a MailChimp action at a // Salesforce connection_id would decrypt Salesforce's token and ship it to // MailChimp's endpoint. - if (!self::belongsToIntegration($handler->getConnection(), $config['slug'])) { + if (!self::belongsToIntegration($handler->getConnection(), $config)) { self::debug($controllerClass, "connection {$connectionId} belongs to a different app"); return; @@ -103,16 +103,21 @@ public static function inject(object $target, string $controllerClass): void } /** - * Whether $connection was created for the integration declaring $slug. + * Whether $connection was created for the integration declaring $config. * * app_slug is stored as the integration's display name ("Zoho CRM") while * $authConfig carries a bare slug ("zohocrm"), so both sides are reduced to - * alphanumerics before comparing. A connection that cannot be loaded at all is - * left to the caller's own null handling. + * alphanumerics before comparing. Names that carry a second brand word + * ("Brevo(Sendinblue)" for slug "sendinblue") cannot reduce to the slug at all, + * so those integrations declare the stored names in $authConfig['aliases'] — + * an explicit list rather than a substring match, which would let a "Zoho" + * connection satisfy every zoho* controller. A connection that cannot be + * loaded at all is left to the caller's own null handling. * - * @param mixed $connection + * @param mixed $connection + * @param array $config Controller's $authConfig */ - private static function belongsToIntegration($connection, string $slug): bool + private static function belongsToIntegration($connection, array $config): bool { if (empty($connection) || empty($connection->app_slug)) { return true; @@ -122,7 +127,9 @@ private static function belongsToIntegration($connection, string $slug): bool return strtolower(preg_replace('/[^a-z0-9]/i', '', (string) $value)); }; - return $normalize($connection->app_slug) === $normalize($slug); + $accepted = array_map($normalize, array_merge([$config['slug']], $config['aliases'] ?? [])); + + return \in_array($normalize($connection->app_slug), $accepted, true); } /** diff --git a/backend/Core/Util/CustomFuncValidator.php b/backend/Core/Util/CustomFuncValidator.php index 4eec9bbe1..9700d0b1c 100644 --- a/backend/Core/Util/CustomFuncValidator.php +++ b/backend/Core/Util/CustomFuncValidator.php @@ -30,8 +30,8 @@ public static function functionValidateHandler($data) $fileContent = $data->flow_details->value; $fileName = $data->flow_details->randomFileName; - if (strpos($fileContent, "defined('ABSPATH')") === false) { - wp_send_json_error(__("Your function must include a defined('ABSPATH') check.", 'bit-integrations')); + if (!self::hasAbspathGuard($fileContent)) { + wp_send_json_error(__("Your function must start with a defined('ABSPATH') check that exits, e.g. if (!defined('ABSPATH')) { exit; }", 'bit-integrations')); return; } @@ -65,7 +65,16 @@ public static function scrapeCustomActionFile($data) $scrapeKey = sanitize_key($data->bit_integrations_scrape_key); $fileLocation = get_transient(Config::withPrefix('scrape_file_') . $scrapeKey); - if (false === $fileLocation || !file_exists($fileLocation)) { + if (false === $fileLocation) { + wp_die(0); + } + + // This route is no_auth()/ignore_token() by necessity (the loopback is an + // unauthenticated self-request), so confine the include here too rather than + // trusting the transient alone. + $fileLocation = self::resolveCustomFunctionFile($fileLocation); + + if ($fileLocation === '') { wp_die(0); } @@ -123,12 +132,7 @@ public static function loopbackValidateContent($fileContent) return false; } - $wp_filesystem = self::getFilesystem(); - if (false === $wp_filesystem) { - wp_send_json_error(__('Unable to initialize filesystem.', 'bit-integrations')); - - return false; - } + $wp_filesystem = FileSystem::instance(); $customDir = self::customFunctionDir($wp_filesystem); if ($customDir === '') { @@ -137,7 +141,7 @@ public static function loopbackValidateContent($fileContent) return false; } - $tmpFile = "{$customDir}/" . Config::withPrefix('tmp_') . md5(wp_rand()) . '.php'; + $tmpFile = "{$customDir}/" . Config::withPrefix('tmp_') . bin2hex(random_bytes(16)) . '.php'; $written = $wp_filesystem->put_contents($tmpFile, $fileContent, FS_CHMOD_FILE); @@ -157,6 +161,41 @@ public static function loopbackValidateContent($fileContent) return $passed; } + /** + * Whether $fileContent opens with a real "not loaded by WordPress -> stop" guard. + * + * The previous check only looked for the substring "defined('ABSPATH')" anywhere in the + * file, which a comment satisfied. This matters beyond tidiness: the custom-function + * directory is protected by .htaccess (Apache) and web.config (IIS), and neither applies + * on nginx, where /wp-content/uploads/**\/*.php is normally handed to PHP-FPM. The guard + * in the file itself is the only portable defence, so it has to actually be there. + * + * @param string $fileContent + * + * @return bool + */ + private static function hasAbspathGuard($fileContent) + { + if (!\is_string($fileContent) || $fileContent === '') { + return false; + } + + // if ( ! defined( 'ABSPATH' ) ) { exit; } — tolerant of spacing, quote style, + // braces, and exit/die/return, but the terminator must follow the condition. + $pattern = '/if\s*\(\s*!\s*(?:\\\\)?defined\s*\(\s*[\'"]ABSPATH[\'"]\s*\)\s*\)\s*\{?\s*(?:exit|die|return)\b/i'; + + if (!preg_match($pattern, $fileContent, $matches, PREG_OFFSET_CAPTURE)) { + return false; + } + + // Must guard the whole file, not sit halfway down it: nothing executable may + // precede it. Allow the opening tag, whitespace, comments and declare(). + $prefix = substr($fileContent, 0, $matches[0][1]); + $prefix = preg_replace('/<\?php|<\?=|\/\*.*?\*\/|\/\/[^\r\n]*|#[^\r\n]*|declare\s*\([^)]*\)\s*;?/s', '', $prefix); + + return trim((string) $prefix) === ''; + } + /** * Whether file modifications are disabled for this site. * Custom actions write and include PHP on disk, so they must honour the @@ -170,6 +209,53 @@ private static function fileModsDisabled() || (\defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT); } + /** + * Resolve a stored custom-action file path to a real file inside the custom-function + * directory, or '' when it points anywhere else. + * + * funcFileLocation travels in flow_details, which is caller-supplied JSON. Everything + * that include()s it must confine it first: an absolute path that merely exists is not + * proof the plugin wrote it. + * + * @param string $fileLocation + * + * @return string Absolute path inside the custom-function directory, or ''. + */ + public static function resolveCustomFunctionFile($fileLocation) + { + if (!\is_string($fileLocation) || $fileLocation === '') { + return ''; + } + + // Reject stream wrappers (phar://, http://) before touching the filesystem. + if (wp_parse_url($fileLocation, PHP_URL_SCHEME) !== null) { + return ''; + } + + $real = realpath($fileLocation); + if ($real === false || !is_file($real)) { + return ''; + } + + if (strtolower(pathinfo($real, PATHINFO_EXTENSION)) !== 'php') { + return ''; + } + + $uploadDir = wp_upload_dir(); + if (empty($uploadDir['basedir'])) { + return ''; + } + + $base = realpath(rtrim($uploadDir['basedir'], '/\\') . '/' . Config::withPrefix('custom_functions')); + if ($base === false) { + return ''; + } + + $base = rtrim($base, '/\\') . DIRECTORY_SEPARATOR; + + return strpos($real, $base) === 0 ? $real : ''; + } + /** * Resolve (and, on first use, create + lock down) the directory that holds * custom-action PHP files. @@ -214,26 +300,6 @@ private static function customFunctionDir($wp_filesystem) return $dir; } - /** - * Get initialized WP filesystem instance. - * - * @return WP_Filesystem_Base|false - */ - private static function getFilesystem() - { - global $wp_filesystem; - - if (empty($wp_filesystem)) { - require_once ABSPATH . '/wp-admin/includes/file.php'; - - if (!WP_Filesystem()) { - return false; - } - } - - return $wp_filesystem instanceof WP_Filesystem_Base ? $wp_filesystem : false; - } - /** * Initialize filesystem, resolve file path, and write custom function content. * @@ -244,12 +310,7 @@ private static function getFilesystem() */ private static function writeCustomFunctionFile($fileName, $fileContent) { - $wp_filesystem = self::getFilesystem(); - if (false === $wp_filesystem) { - wp_send_json_error(__('Unable to initialize filesystem.', 'bit-integrations')); - - return false; - } + $wp_filesystem = FileSystem::instance(); $customDir = self::customFunctionDir($wp_filesystem); if ($customDir === '') { @@ -265,7 +326,7 @@ private static function writeCustomFunctionFile($fileName, $fileContent) return false; } $fileLocation = "{$customDir}/{$safeFileName}.php"; - $previousContent = file_exists($fileLocation) ? file_get_contents($fileLocation) : null; + $previousContent = $wp_filesystem->exists($fileLocation) ? $wp_filesystem->get_contents($fileLocation) : null; $written = $wp_filesystem->put_contents($fileLocation, $fileContent, FS_CHMOD_FILE); if (!$written) { @@ -296,7 +357,10 @@ private static function writeCustomFunctionFile($fileName, $fileContent) */ private static function loopbackCheck($fileLocation, $previousContent, $wp_filesystem) { - $scrapeKey = md5(wp_rand()); + // Not md5(wp_rand()): wp_rand() returns an int below mt_getrandmax(), so hashing it + // leaves only ~2^31 possible keys — enumerable inside the 60s window by anyone, since + // the scrape route is unauthenticated. random_bytes() gives a real 128-bit key. + $scrapeKey = bin2hex(random_bytes(16)); set_transient(Config::withPrefix('scrape_file_') . $scrapeKey, $fileLocation, 60); diff --git a/backend/Core/Util/FileSystem.php b/backend/Core/Util/FileSystem.php new file mode 100644 index 000000000..3a023411c --- /dev/null +++ b/backend/Core/Util/FileSystem.php @@ -0,0 +1,100 @@ +get_contents($path); + } + + /** + * @param string $path + * @param string $contents + * + * @return bool + */ + public static function write($path, $contents) + { + return self::instance()->put_contents($path, $contents, FS_CHMOD_FILE); + } + + /** + * @param string $path + * + * @return bool + */ + public static function exists($path) + { + return self::instance()->exists($path); + } + + /** + * @param string $path + * + * @return bool + */ + public static function isDir($path) + { + return self::instance()->is_dir($path); + } + + /** + * @param string $path + * + * @return bool + */ + public static function delete($path) + { + return self::instance()->delete($path); + } +} diff --git a/backend/Core/Util/Hash.php b/backend/Core/Util/Hash.php index 97013aec6..0bebbbc83 100644 --- a/backend/Core/Util/Hash.php +++ b/backend/Core/Util/Hash.php @@ -61,7 +61,7 @@ public static function encrypt($data) throw new RuntimeException('Unable to encrypt value: ' . esc_html((string) openssl_error_string())); } - return self::V2_PREFIX . urlencode(base64_encode($iv . $tag . $cipherRaw)); + return self::V2_PREFIX . rawurlencode(base64_encode($iv . $tag . $cipherRaw)); } public static function decrypt($encryptedData) @@ -126,6 +126,16 @@ private static function secretKey() $secretKey = Config::getOption('secret_key'); if (!$secretKey) { + // Prefer a key held in wp-config.php so a database-only compromise does not hand + // over the credentials stored beside it. Only consulted when no key has been + // persisted yet: switching keys under existing ciphertext would make every stored + // credential undecryptable, so an install that already has one keeps it. + if (\defined('BIT_INTEGRATIONS_SECRET_KEY') && \is_string(\constant('BIT_INTEGRATIONS_SECRET_KEY')) && \constant('BIT_INTEGRATIONS_SECRET_KEY') !== '') { + self::$cachedKey = \constant('BIT_INTEGRATIONS_SECRET_KEY'); + + return self::$cachedKey; + } + $generated = \function_exists('wp_generate_password') ? wp_generate_password(64, true, true) : Config::VAR_PREFIX . bin2hex(random_bytes(32)); diff --git a/backend/Core/Util/Helper.php b/backend/Core/Util/Helper.php index 8f38a7ef2..31b3c7b71 100644 --- a/backend/Core/Util/Helper.php +++ b/backend/Core/Util/Helper.php @@ -102,7 +102,7 @@ public static function uploadFeatureImg($filePath, $postID) if ($safeFilePath === '') { continue; } - $fileContent = file_get_contents($safeFilePath); + $fileContent = FileSystem::read($safeFilePath); } // prepare upload image to WordPress Media Library @@ -143,7 +143,7 @@ public static function singleFileMoveWpMedia($filePath, $postId) if ($filePath !== '') { $imgFileName = basename($filePath); // prepare upload image to WordPress Media Library - $upload = wp_upload_bits($imgFileName, null, file_get_contents($filePath)); + $upload = wp_upload_bits($imgFileName, null, FileSystem::read($filePath)); if (!empty($upload['error']) || empty($upload['file'])) { return; @@ -184,14 +184,13 @@ public static function multiFileMoveWpMedia($files, $postId) if ($file !== '') { $imgFileName = basename($file); // prepare upload image to WordPress Media Library - $upload = wp_upload_bits($imgFileName, null, file_get_contents($file)); + $upload = wp_upload_bits($imgFileName, null, FileSystem::read($file)); if (!empty($upload['error']) || empty($upload['file'])) { continue; } $imageFile = $upload['file']; - // echo $imageFile; $wpFileType = wp_check_filetype($imageFile, null); // Attachment attributes for file $attachment = [ @@ -261,7 +260,6 @@ public static function extractValueFromPath($data, $path, $triggerEntity = 'trig if (\is_array($data)) { if (!isset($data[$currentPart])) { - // wp_send_json_error(new WP_Error($triggerEntity, __('Index out of bounds or invalid', 'bit-integrations'))); return; } @@ -270,14 +268,12 @@ public static function extractValueFromPath($data, $path, $triggerEntity = 'trig if (\is_object($data)) { if (!property_exists($data, $currentPart)) { - // wp_send_json_error(new WP_Error($triggerEntity, __('Invalid path', 'bit-integrations'))); return; } return self::extractValueFromPath($data->{$currentPart}, $parts, $triggerEntity); } - // wp_send_json_error(new WP_Error($triggerEntity, __('Invalid path', 'bit-integrations'))); } public static function parseFlowDetails($flowDetails) @@ -319,7 +315,7 @@ public static function getAcfFieldData($acfFieldGroups, $postId) foreach ($acfFieldGroups as $group) { foreach (acf_get_fields($group['ID']) as $field) { - $data[$field['_name']] = get_post_meta($postId, $field['_name'])[0]; + $data[$field['_name']] = get_post_meta($postId, $field['_name'])[0] ?? null; } } @@ -529,7 +525,7 @@ public static function convertStringToArray($data, $separator = ',') public static function jsonEncodeDecode($data) { - return json_decode(json_encode($data), true); + return json_decode(wp_json_encode($data), true); } public static function getPostIdFromReferer($referer) diff --git a/backend/Core/Util/HttpHelper.php b/backend/Core/Util/HttpHelper.php index 9e4b17947..0ba412606 100644 --- a/backend/Core/Util/HttpHelper.php +++ b/backend/Core/Util/HttpHelper.php @@ -136,7 +136,7 @@ public static function localFile($boundary, $name, CURLFile $file) . '"; filename="' . basename($file->getFilename()) . '"' . "\r\n"; $payload .= 'Content-Type: ' . $file->getMimeType() . "\r\n"; $payload .= "\r\n"; - $payload .= file_get_contents($file->getFilename()); + $payload .= FileSystem::read($file->getFilename()); $payload .= "\r\n"; return $payload; diff --git a/backend/Core/Util/Route.php b/backend/Core/Util/Route.php index 1efc187d3..1b24dfe5d 100644 --- a/backend/Core/Util/Route.php +++ b/backend/Core/Util/Route.php @@ -17,6 +17,17 @@ final class Route private static $_sanitize_post_content = false; + /** + * Baseline authorization applied to routes registered from here on. + * 'any' — any Bit Integrations capability (core app routes; their controllers + * then apply the precise per-endpoint check). + * 'write' — a capability that implies authority to change something. Used for + * integration-owned routes, which carry no check of their own. + * + * @var string + */ + private static $_default_access = 'any'; + public static function get($hook, $invokeable) { return static::request('GET', $hook, $invokeable); @@ -74,6 +85,7 @@ public static function request($method, $hook, $invokeable) } static::$_invokeable[Config::VAR_PREFIX . $hook][$method] = $invokeable; + static::$_invokeable[Config::VAR_PREFIX . $hook][$method . '_access'] = static::$_default_access; Hooks::add('wp_ajax_' . Config::VAR_PREFIX . $hook, [__CLASS__, 'action']); @@ -114,11 +126,17 @@ public static function action() // A valid nonce proves the request origin, not the caller's authority. // Every route except those explicitly registered as public (no_auth()) - // requires the caller to hold at least one Bit Integrations capability, - // so a leaked/shared nonce can never by itself reach an action or - // trigger handler. + // requires the caller to hold a Bit Integrations capability, so a leaked or + // shared nonce can never by itself reach an action or trigger handler. + // Integration-owned routes are registered under the stricter 'write' baseline + // (see HookService) because they carry no capability check of their own. $isPublicRoute = !empty(static::$_invokeable[$action][$requestMethod . '_public']); - if (!$isPublicRoute && !Capabilities::hasIntegrationAccess()) { + $requiresWrite = (static::$_invokeable[$action][$requestMethod . '_access'] ?? 'any') === 'write'; + $isAuthorized = $requiresWrite + ? Capabilities::hasIntegrationWriteAccess() + : Capabilities::hasIntegrationAccess(); + + if (!$isPublicRoute && !$isAuthorized) { wp_send_json_error( __('You do not have permission to perform this action.', 'bit-integrations'), 403 @@ -191,6 +209,19 @@ public static function action() } } + /** + * Set the baseline authorization for routes registered after this call. + * Call with 'any' to restore the default once the group has been registered. + * + * @param string $access 'any'|'write' + * + * @return void + */ + public static function defaultAccess($access) + { + self::$_default_access = $access === 'write' ? 'write' : 'any'; + } + public static function no_auth() { self::$_no_auth = true; diff --git a/backend/Core/Util/SmartTags.php b/backend/Core/Util/SmartTags.php index 391921e3f..95d66b51c 100644 --- a/backend/Core/Util/SmartTags.php +++ b/backend/Core/Util/SmartTags.php @@ -64,7 +64,10 @@ public static function getSmartTagValue($key, $isReferer = false) '_bi_ip_address' => IpTool::getIP(), '_bi_browser_name' => isset($browser) ? $browser : '', '_bi_operating_system' => isset($operating) ? $operating : '', - '_bi_random_digit_num' => time(), + // Was time(): fully predictable despite the name, which is a hazard when the tag + // is mapped into a token, coupon or reference field. Use _bi_current_time for a + // timestamp. + '_bi_random_digit_num' => wp_rand(1000000000, 9999999999), '_bi_user_id' => (isset($data['user']->ID) ? $data['user']->ID : ' '), '_bi_user_first_name' => (isset($data['user']->first_name) ? $data['user']->first_name : ' '), '_bi_user_last_name' => (isset($data['user']->last_name) ? $data['user']->last_name : ' '), diff --git a/backend/Flow/Flow.php b/backend/Flow/Flow.php index 6efb23365..423746824 100644 --- a/backend/Flow/Flow.php +++ b/backend/Flow/Flow.php @@ -467,7 +467,9 @@ public static function execute($triggered_entity, $triggered_entity_id, $data, $ continue; } - $integrationName = \is_null($flowData->flow_details->type) ? null : ucfirst(str_replace(' ', '', $flowData->flow_details->type)); + // Same normalizer the custom-action capability gate uses — the two must never + // diverge, or a type that skips the gate can still resolve to an action class. + $integrationName = \is_null($flowData->flow_details->type) ? null : self::normalizeActionType($flowData->flow_details->type); switch ($integrationName) { case 'Brevo(Sendinblue)': @@ -600,6 +602,23 @@ private static function updateFlowTrigger($saveStatus) } } + /** + * Stop a non-administrator from acting on a flow whose action is a custom action. + * + * Public so callers outside this class that can cause a flow to run (log re-execution) + * enforce the same administrator boundary as save/update/delete/toggle. + * + * @param mixed $flowDetails Raw or decoded flow_details + * + * @return void + */ + public static function guardCustomActionFlowDetails($flowDetails) + { + if (self::isCustomActionFlowDetails($flowDetails)) { + self::requireCustomActionCapability(); + } + } + private static function requireCustomActionCapability() { if (!Capabilities::Check('manage_options')) { @@ -648,6 +667,32 @@ private static function isCustomActionFlowDetails($flowDetails) $flowDetails = json_decode($flowDetails); } - return \is_object($flowDetails) && !empty($flowDetails->type) && $flowDetails->type === 'CustomAction'; + if (!\is_object($flowDetails) || empty($flowDetails->type)) { + return false; + } + + return self::normalizeActionType($flowDetails->type) === 'CustomAction'; + } + + /** + * Canonical form of a flow's action type. + * + * This MUST stay the single normalizer used by both the capability gate + * (isCustomActionFlowDetails) and the dispatcher (execute). When the gate compared the + * raw string while execute() normalized it, "Custom Action" and "customAction" both + * skipped the administrator check yet still resolved to CustomActionController — which + * include()s a caller-supplied path. + * + * @param null|string $type + * + * @return string + */ + private static function normalizeActionType($type) + { + if (\is_null($type)) { + return ''; + } + + return ucfirst(str_replace(' ', '', (string) $type)); } } diff --git a/backend/Log/LogHandler.php b/backend/Log/LogHandler.php index 86f3673c1..c1c1ff241 100644 --- a/backend/Log/LogHandler.php +++ b/backend/Log/LogHandler.php @@ -506,6 +506,11 @@ public static function reexecute($data) $flowData = $flows[0]; + // Re-execution runs the flow's action for real, so it has to clear the same + // administrator gate that save/update/delete/toggle apply to custom actions — + // otherwise manage_integrations alone could invoke admin-authored PHP on demand. + Flow::guardCustomActionFlowDetails($flowData->flow_details ?? null); + if ($flowData->status != 1) { wp_send_json_error(__('Integration is not active', 'bit-integrations')); } diff --git a/backend/Triggers/ActionHook/Hooks.php b/backend/Triggers/ActionHook/Hooks.php index e4f0c1e63..0ea36a84f 100644 --- a/backend/Triggers/ActionHook/Hooks.php +++ b/backend/Triggers/ActionHook/Hooks.php @@ -9,6 +9,10 @@ use BitApps\Integrations\Core\Util\StoreInCache; use BitApps\Integrations\Triggers\ActionHook\ActionHookController; +// These file-scope variables already carry the plugin slug as their prefix. +// Plugin Check infers prefixes from hook names rather than the slug, and this +// plugin fires third-party hooks, so `bit_integrations` never makes its list. +// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound if (!Helper::isProActivate()) { $bit_integrations_flows = StoreInCache::getActionHookFlows() ?? []; @@ -23,3 +27,4 @@ } } } +// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound diff --git a/backend/Triggers/BitCrm/BitCrmController.php b/backend/Triggers/BitCrm/BitCrmController.php index 3f4a47aa5..ac0c7835f 100644 --- a/backend/Triggers/BitCrm/BitCrmController.php +++ b/backend/Triggers/BitCrm/BitCrmController.php @@ -36,6 +36,7 @@ public static function info() public function getAllEvents() { if (!self::isPluginInstalled()) { + // translators: %s: Plugin name wp_send_json_error(\sprintf(__('%s is not installed or activated', 'bit-integrations'), 'Bit CRM')); } @@ -594,7 +595,7 @@ public static function handleInvoicesTrashed($ids) * The typed ids (`bit_crm/task_created`, …) are flow keys, not WordPress * hooks — no such hook exists. * - * @param string $event one of created|updated|status_updated + * @param string $event one of created|updated|status_updated * @param mixed $activity * @param array $extra */ diff --git a/backend/Triggers/FallbackTrigger/Hooks.php b/backend/Triggers/FallbackTrigger/Hooks.php index 4669265de..46445b38a 100644 --- a/backend/Triggers/FallbackTrigger/Hooks.php +++ b/backend/Triggers/FallbackTrigger/Hooks.php @@ -10,6 +10,10 @@ use BitApps\Integrations\Triggers\FallbackTrigger\FallbackHooks; use BitApps\Integrations\Triggers\FallbackTrigger\FallbackTriggerController; +// These file-scope variables already carry the plugin slug as their prefix. +// Plugin Check infers prefixes from hook names rather than the slug, and this +// plugin fires third-party hooks, so `bit_integrations` never makes its list. +// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound if (!Helper::isProActivate()) { $bit_integrations_entities = StoreInCache::getFallbackFlowEntities() ?? []; @@ -28,3 +32,4 @@ } } } +// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound diff --git a/backend/Triggers/FallbackTrigger/TriggerFallback.php b/backend/Triggers/FallbackTrigger/TriggerFallback.php index 99465401f..0146e4041 100644 --- a/backend/Triggers/FallbackTrigger/TriggerFallback.php +++ b/backend/Triggers/FallbackTrigger/TriggerFallback.php @@ -4,6 +4,7 @@ use BitApps\Integrations\Config; use BitApps\Integrations\Core\Util\Common; +use BitApps\Integrations\Core\Util\FileSystem; use BitApps\Integrations\Core\Util\DateTimeHelper; use BitApps\Integrations\Core\Util\Helper; use BitApps\Integrations\Flow\Flow; @@ -2435,7 +2436,6 @@ public static function gamipressHandleGainAchievementType($user_id, $achievement 'post_url' => get_permalink($achievement_id), 'post_type' => $postData->post_type, 'post_author_id' => $postData->post_author, - // 'post_author_email' => $postData->post_author_email, 'post_content' => $postData->post_content, 'post_parent_id' => $postData->post_parent, ]; @@ -2456,7 +2456,6 @@ public static function gamipressHandleRevokeAchieve($user_id, $achievement_id, $ 'post_url' => get_permalink($achievement_id), 'post_type' => isset($expectedData->post_type), 'post_author_id' => isset($expectedData->post_author), - // 'post_author_email' => $postData->post_author_email, 'post_content' => isset($expectedData->post_content), 'post_parent_id' => isset($expectedData->post_parent), ]; @@ -2779,7 +2778,7 @@ public static function happySaveImage($base64_img, $title) $hashed_filename = md5($filename . microtime()) . '_' . $filename; // Save the image in the uploads directory. - $upload_file = file_put_contents($upload_path . '/' . $hashed_filename, $decoded); + $upload_file = FileSystem::write($upload_path . '/' . $hashed_filename, $decoded); if ($upload_file) { return $upload_path . '/' . $hashed_filename; } diff --git a/backend/Triggers/TriggerController.php b/backend/Triggers/TriggerController.php index ffc848b08..e2cc7e585 100644 --- a/backend/Triggers/TriggerController.php +++ b/backend/Triggers/TriggerController.php @@ -68,7 +68,15 @@ public static function getTestData($data) wp_send_json_error(__("User don't have permission to access this page", 'bit-integrations')); } - $triggerName = $data->triggered_entity_id; + // Reduced to [a-z0-9_-] before it becomes an option name: this value is + // caller-supplied and is otherwise interpolated straight into the key that + // update_option()/delete_option() write. + $triggerName = self::sanitizeTestDataKey($data->triggered_entity_id ?? ''); + + if ($triggerName === '') { + wp_send_json_error(__('Invalid trigger id', 'bit-integrations')); + } + $testData = get_option(Config::withPrefix("{$triggerName}_test")); if ($testData === false) { @@ -88,7 +96,11 @@ public static function removeTestData($data) wp_send_json_error(__("User don't have permission to access this page", 'bit-integrations')); } - $triggerName = $data->triggered_entity_id; + $triggerName = self::sanitizeTestDataKey($data->triggered_entity_id ?? ''); + + if ($triggerName === '') { + wp_send_json_error(__('Invalid trigger id', 'bit-integrations')); + } if (\is_object($data) && property_exists($data, 'reset') && $data->reset) { $testData = update_option(Config::withPrefix("{$triggerName}_test"), []); @@ -118,4 +130,31 @@ public static function saveListedTriggers($data) wp_send_json_success(__('Listed trigger saved successfully', 'bit-integrations')); } + + /** + * Reduce a caller-supplied trigger/entity id to the character set that is safe to + * interpolate into a `bit_integrations_{id}_test` option name. + * + * Kept deliberately permissive: real trigger ids include slashes and dots + * (`elementor_pro/forms/new_record`), so this strips control characters, whitespace + * and anything else that has no business in an option name rather than allow-listing + * a shape that would break triggers shipped by the Pro plugin. The + * `bit_integrations_` prefix and `_test` suffix already confine which options can be + * reached; this stops the key itself from being arbitrary. + * + * @param mixed $value + * + * @return string + */ + private static function sanitizeTestDataKey($value) + { + if (!\is_scalar($value)) { + return ''; + } + + $key = (string) preg_replace('/[^A-Za-z0-9_\-\/.:]/', '', (string) $value); + + // option_name is a 191-char column; leave room for the prefix and suffix. + return substr($key, 0, 150); + } } diff --git a/bitwpfi.php b/bitwpfi.php index 1963dac17..11534d41e 100644 --- a/bitwpfi.php +++ b/bitwpfi.php @@ -4,7 +4,7 @@ * Plugin Name: Bit Integrations * Plugin URI: https://bitapps.pro/bit-integrations * Description: Bit Integrations is a platform that integrates with over 300+ different platforms to help with various tasks on your WordPress site, like WooCommerce, Form builder, Page builder, LMS, Sales funnels, Bookings, CRM, Webhooks, Email marketing, Social media and Spreadsheets, etc - * Version: 2.10.0 + * Version: 2.10.1 * Author: Automation & Integration Plugin - Bit Apps * Author URI: https://bitapps.pro * Text Domain: bit-integrations @@ -12,7 +12,8 @@ * Requires at least: 5.1 * Tested up to: 7.0 * Domain Path: /languages - * License: GPLv2 or later + * License: GPL-2.0-or-later + * License URI: https://www.gnu.org/licenses/gpl-2.0.html */ use BitApps\Integrations\Config; @@ -33,7 +34,7 @@ * * @deprecated 2.7.8 Use Config::VERSION instead. */ -define('BTCBI_VERSION', '2.10.0'); +define('BTCBI_VERSION', '2.10.1'); /** * deprecated since version 2.7.8. * @@ -57,6 +58,7 @@ function btcbi_activate_plugin($network_wide) { bit_integrations_activate_plugin($network_wide); } +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- `bit_integrations_` is the plugin slug; Plugin Check infers prefixes from hook names and misses it because the plugin fires third-party hooks. function bit_integrations_activate_plugin($network_wide) { global $wp_version; @@ -86,6 +88,7 @@ function btcbi_deactivate_plugin($network_wide) bit_integrations_deactivate_plugin($network_wide); } +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- `bit_integrations_` is the plugin slug; Plugin Check infers prefixes from hook names and misses it because the plugin fires third-party hooks. function bit_integrations_deactivate_plugin($network_wide) { global $wp_version; @@ -114,6 +117,7 @@ function btcbi_uninstall_plugin() bit_integrations_uninstall_plugin(); } +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- `bit_integrations_` is the plugin slug; Plugin Check infers prefixes from hook names and misses it because the plugin fires third-party hooks. function bit_integrations_uninstall_plugin() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- hook is prefixed via Config::VAR_PREFIX. diff --git a/frontend/src/Utils/StaticData/tutorialLinks.js b/frontend/src/Utils/StaticData/tutorialLinks.js index a8619269c..81d363c88 100644 --- a/frontend/src/Utils/StaticData/tutorialLinks.js +++ b/frontend/src/Utils/StaticData/tutorialLinks.js @@ -226,7 +226,7 @@ const tutorialLinks = { docLink: 'https://bit-integrations.com/wp-docs/actions/mailerlite-integrations/' }, instasent: { - docLink: '#' + docLink: 'https://bit-integrations.com/wp-docs/actions/instasent-integration-as-an-action/' }, mailchimp: { youTubeLink: 'https://www.youtube.com/playlist?list=PL7c6CDwwm-ALUaeqiK9GwBSxVkAod1PzP', @@ -294,7 +294,7 @@ const tutorialLinks = { }, postCreation: { youTubeLink: 'https://www.youtube.com/playlist?list=PL7c6CDwwm-AIXE9-3U0gaFuqscE32OHcz', - docLink: 'https://bit-integrations.com/wp-docs/actions/post-creation-integrations/' + docLink: 'https://bit-integrations.com/wp-docs/actions/wp-post-creation-integration-as-an-action/' }, pCloud: { youTubeLink: 'https://www.youtube.com/playlist?list=PL7c6CDwwm-AKN8YP9ZiA2Nr2YJkPsQL8x', @@ -314,7 +314,8 @@ const tutorialLinks = { }, registration: { youTubeLink: 'https://www.youtube.com/playlist?list=PL7c6CDwwm-AJq1l8SeKisLk60ewQRreg1', - docLink: 'https://bit-integrations.com/wp-docs/actions/registration-integrations/' + docLink: + 'https://bit-integrations.com/wp-docs/actions/wp-user-registration-integration-as-an-action/' }, restrictContent: { youTubeLink: 'https://www.youtube.com/playlist?list=PL7c6CDwwm-AJq1l8SeKisLk60ewQRreg1', @@ -680,7 +681,50 @@ const tutorialLinks = { }, sender: { youTubeLink: '', - docLink: 'https://bit-integrations.com/wp-docs/actions/sender-integrations/' + docLink: 'https://bit-integrations.com/wp-docs/actions/sender-integration-as-an-action/' + }, + webbaBooking: { + youTubeLink: '', + docLink: + 'https://bit-integrations.com/wp-docs/actions/webba-booking-calendar-integration-as-an-action/' + }, + mainWP: { + youTubeLink: '', + docLink: 'https://bit-integrations.com/wp-docs/actions/mainwp-integration-as-an-action/' + }, + wsms: { + youTubeLink: '', + docLink: 'https://bit-integrations.com/wp-docs/actions/wsms-integration-as-an-action/' + }, + wordPress: { + youTubeLink: '', + docLink: 'https://bit-integrations.com/wp-docs/actions/wordpress-integration-as-an-action/' + }, + zendeskSupport: { + youTubeLink: '', + docLink: 'https://bit-integrations.com/wp-docs/actions/zendesk-support-integration-as-an-action/' + }, + moreConvertWishlist: { + youTubeLink: '', + docLink: + 'https://bit-integrations.com/wp-docs/actions/moreconvert-wishlist-integration-as-an-action/' + }, + secureCustomFields: { + youTubeLink: '', + docLink: + 'https://bit-integrations.com/wp-docs/actions/secure-custom-fields-integration-as-an-action/' + }, + hefflCRM: { + youTubeLink: '', + docLink: 'https://bit-integrations.com/wp-docs/actions/heffl-crm-integration-as-an-action/' + }, + ivyForms: { + youTubeLink: '', + docLink: 'https://bit-integrations.com/wp-docs/actions/ivyforms-integration-as-an-action/' + }, + weDocs: { + youTubeLink: '', + docLink: 'https://bit-integrations.com/wp-docs/actions/wedocs-integration-as-an-action/' } } export default tutorialLinks diff --git a/frontend/src/components/AllIntegrations/CustomApi/CustomApiAuthorization.jsx b/frontend/src/components/AllIntegrations/CustomApi/CustomApiAuthorization.jsx index 51c989e92..4134972ce 100644 --- a/frontend/src/components/AllIntegrations/CustomApi/CustomApiAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/CustomApi/CustomApiAuthorization.jsx @@ -213,14 +213,16 @@ export default function CustomApiAuthorization({ )}
- + {!isInfo && ( + + )}
) diff --git a/frontend/src/components/AllIntegrations/HefflCRM/HefflCRMAuthorization.jsx b/frontend/src/components/AllIntegrations/HefflCRM/HefflCRMAuthorization.jsx index 7a1659623..7b61950b1 100644 --- a/frontend/src/components/AllIntegrations/HefflCRM/HefflCRMAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/HefflCRM/HefflCRMAuthorization.jsx @@ -10,6 +10,7 @@ export default function HefflCRMAuthorization({ hefflCRMConf, setHefflCRMConf, s step={step} setStep={setStep} isInfo={isInfo} + tutorialLinkKey="hefflCRM" authDetails={{ authType: AUTH_TYPES.API_KEY, apiEndpoint: 'https://api.heffl.com/api/v1/leads?limit=1', diff --git a/frontend/src/components/AllIntegrations/IntegInfo.jsx b/frontend/src/components/AllIntegrations/IntegInfo.jsx index d8c8b9282..057cbd6b0 100644 --- a/frontend/src/components/AllIntegrations/IntegInfo.jsx +++ b/frontend/src/components/AllIntegrations/IntegInfo.jsx @@ -2,13 +2,17 @@ /* eslint-disable react/jsx-no-useless-fragment */ /* eslint-disable react/no-unstable-nested-components */ /* eslint-disable react/jsx-no-undef */ -import { lazy, memo, Suspense, useEffect, useState } from 'react' -import { Link, useParams } from 'react-router' +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react' +import toast from 'react-hot-toast' +import { Link, useNavigate, useParams } from 'react-router' import useFetch from '../../hooks/useFetch' +import bitsFetch from '../../Utils/bitsFetch' import { __ } from '../../Utils/i18nwrap' +import Note from '../Utilities/Note' import SnackMsg from '../Utilities/SnackMsg' import { useRecoilValue } from 'recoil' import { $appConfigState } from '../../GlobalStates' +import { ConnectionSwitchProvider } from '../Connections/ConnectionSwitchContext' const Loader = lazy(() => import('../Loaders/Loader')) const PaidMembershipProAuthorization = lazy( @@ -214,8 +218,42 @@ const B2BKingAuthorization = lazy(() => import('./B2BKing/B2BKingAuthorization') const UserRegistrationMembershipAuthorization = lazy( () => import('./UserRegistrationMembership/UserRegistrationMembershipAuthorization') ) +const TutorLmsAuthorization = lazy(() => import('./TutorLms/TutorLmsAuthorization')) +const LearnDashAuthorization = lazy(() => import('./LearnDash/LearnDashAuthorization')) +const LifterLmsAuthorization = lazy(() => import('./LifterLms/LifterLmsAuthorization')) +const GamiPressAuthorization = lazy(() => import('./GamiPress/GamiPressAuthorization')) +const AffiliateAuthorization = lazy(() => import('./Affiliate/AffiliateAuthorization')) +const BuddyBossAuthorization = lazy(() => import('./BuddyBoss/BuddyBossAuthorization')) +const SliceWpAuthorization = lazy(() => import('./SliceWp/SliceWpAuthorization')) +const CustomApiAuthorization = lazy(() => import('./CustomApi/CustomApiAuthorization')) -const IntegrationInfo = memo(({ integrationConf, location }) => { +const IntegrationInfoFallback = ({ integrationConf, editUrl }) => ( +
+
+ {__('Integration Name:', 'bit-integrations')} +
+ + + + + + {__('Integration Settings', 'bit-integrations')} +
+ +
+) + +const IntegrationInfo = memo(({ integrationConf, location, editUrl }) => { switch (integrationConf.type) { case 'Zoho CRM': return ( @@ -343,6 +381,7 @@ const IntegrationInfo = memo(({ integrationConf, location }) => { case 'KonnectzIT': return case 'Ants & Apps': + case 'Ant Apps': return case 'Zoho Flow': return ( @@ -351,6 +390,7 @@ const IntegrationInfo = memo(({ integrationConf, location }) => { case 'Telegram': return case 'Fluent CRM': + case 'Fluent Crm': return case 'Encharge': return @@ -619,6 +659,7 @@ const IntegrationInfo = memo(({ integrationConf, location }) => { case 'ACPT': return case 'WishlistMember': + case 'Wishlist Member': return case 'CreatorLms': return @@ -682,25 +723,63 @@ const IntegrationInfo = memo(({ integrationConf, location }) => { return case 'B2BKing': return + case 'Tutor Lms': + return + case 'LearnDash': + return + case 'LifterLms': + return + case 'GamiPress': + return + case 'Affiliate': + return + case 'BuddyBoss': + return + case 'SliceWp': + return + case 'CustomApi': + return default: - return <> + // Actions with no authorization UI of their own (site-local ones like Mail + // or Post Creation, and anything this build has no component for) used to + // render an empty page here. + return } }) +// Same route split saveActionConf() uses: the plain flow/update route runs every +// value through sanitize_text_field(), which would strip the HTML message bodies +// of these actions on save. +const RICH_CONTENT_TYPES = ['Mail', 'Telegram', 'WhatsApp'] + +const getUpdateAction = confType => { + if (confType === 'CustomAction') return 'flow/custom-action/update' + if (RICH_CONTENT_TYPES.includes(confType)) return 'flow/sanitize_post_content/update' + + return 'flow/update' +} + export default function IntegInfo() { const { id, type } = useParams() const btcbi = useRecoilValue($appConfigState) const [snack, setSnackbar] = useState({ show: false }) const [integrationConf, setIntegrationConf] = useState({}) - const { data, isLoading, isError } = useFetch({ + const [integration, setIntegration] = useState(null) + const [isSwitching, setIsSwitching] = useState(false) + const [switchedConnection, setSwitchedConnection] = useState(false) + const navigate = useNavigate() + // Keyed by id (like EditInteg) so one flow's cached response can't be shown — + // or written back — while another flow's info page is open. + const { data, isLoading, isError, mutate } = useFetch({ payload: { id }, - action: 'flow/get', + action: ['flow/get', id], method: 'post' }) useEffect(() => { if (!isError && !isLoading) { if (data?.success) { + setIntegration(data?.data?.integration) setIntegrationConf(data?.data?.integration.flow_details) } else { setSnackbar({ @@ -713,6 +792,71 @@ export default function IntegInfo() { } }, [data]) + const switchConnection = useCallback( + async (connectionId, extraConf = {}) => { + if (!integration?.id || !connectionId) return + + const nextConf = { ...integrationConf, ...extraConf, connection_id: connectionId } + setIsSwitching(true) + + try { + const res = await bitsFetch( + { + id: integration.id, + name: integration.name, + trigger: integration.triggered_entity, + triggered_entity_id: integration.triggered_entity_id, + flow_details: nextConf + }, + getUpdateAction(nextConf?.type) + ) + + if (res?.success) { + setIntegrationConf(nextConf) + setSwitchedConnection(true) + // EditInteg shares this cache entry — refresh it so the edit wizard + // opens on the switched connection instead of the stale response. + mutate() + toast.success(__('Connection switched successfully', 'bit-integrations')) + return + } + + toast.error( + `${__('Failed to switch connection Cause:', 'bit-integrations')} ${res?.data?.data || res?.data || ''}` + ) + } catch (error) { + toast.error( + `${__('Failed to switch connection Cause:', 'bit-integrations')} ${error?.message || 'Unknown error'}` + ) + } finally { + setIsSwitching(false) + } + }, + [integration, integrationConf, mutate] + ) + + const editUrl = `/flow/action/edit/${id}` + const goToEditIntegration = useCallback(() => navigate(editUrl), [navigate, editUrl]) + + // Only connection-based integrations carry a connection_id; the legacy ones + // keep credentials inline in flow_details and stay read-only here. + const connectionSwitch = useMemo( + () => ({ + enabled: Boolean(integrationConf?.connection_id), + isSwitching, + switched: switchedConnection, + onSwitch: switchConnection, + onNext: goToEditIntegration + }), + [ + integrationConf?.connection_id, + isSwitching, + switchedConnection, + switchConnection, + goToEditIntegration + ] + ) + // route is info/:id but for redirect uri need to make new/:type // let location = window.location.toString() // const toReplaceInd = location.indexOf('/info') @@ -732,9 +876,11 @@ export default function IntegInfo() {
- }> - - + + }> + + + ) } diff --git a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/Body.jsx b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/Body.jsx index e40f5fba6..643231fb0 100644 --- a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/Body.jsx +++ b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/Body.jsx @@ -25,7 +25,7 @@ function Body({ webHooks, setWebHooks, isInfo, setTab }) { }` setWebHooks(tmpConf) } - setTab(3) + setTab(4) }, []) const addParam = () => { diff --git a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/Params.jsx b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/Params.jsx index 71ad3aa9e..193f64035 100644 --- a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/Params.jsx +++ b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/Params.jsx @@ -1,11 +1,12 @@ import { useEffect } from 'react' -import MultiSelect from 'react-multiple-select-dropdown-lite' import { __ } from '../../../../Utils/i18nwrap' import CloseIcn from '../../../../Icons/CloseIcn' import TrashIcn from '../../../../Icons/TrashIcn' import Button from '../../../Utilities/Button' +import FlowFormFieldsOptions from '../FlowFormFieldsOptions' +import SmartTagOptions from '../SmartTagOptions' -function Params({ formFields, webHooks, setWebHooks, isInfo, setTab }) { +function Params({ webHooks, setWebHooks, isInfo, setTab }) { useEffect(() => { setTab(1) }, []) @@ -49,7 +50,7 @@ function Params({ formFields, webHooks, setWebHooks, isInfo, setTab }) { } return (
-
{__('Add Url Parameter: (optional)', 'bit-integrations')}
+
{__('Url Query Parameters: (optional)', 'bit-integrations')}
@@ -82,13 +83,15 @@ function Params({ formFields, webHooks, setWebHooks, isInfo, setTab }) { - ({ label: f.label, value: `\${${f.name}}` }))} - className="btcd-paper-drpdwn wdt-200 ml-2" - singleSelect - onChange={val => setFromField(val, itm, webHooks, setWebHooks)} - defaultValue={itm.split('=')[1]} - /> +
)}
diff --git a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/PathParams.jsx b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/PathParams.jsx new file mode 100644 index 000000000..52b387639 --- /dev/null +++ b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/PathParams.jsx @@ -0,0 +1,139 @@ +import { useEffect, useMemo } from 'react' +import TrashIcn from '../../../../Icons/TrashIcn' +import { __ } from '../../../../Utils/i18nwrap' +import Button from '../../../Utilities/Button' +import Note from '../../../Utilities/Note' +import FlowFormFieldsOptions from '../FlowFormFieldsOptions' +import SmartTagOptions from '../SmartTagOptions' + +// matches `{id}` placeholders but keeps `${field}` smart tags out of the way +const PLACEHOLDER_PATTERN = '(\\$?)\\{([^{}\\s/?#]+)\\}' + +export const getPathPlaceholders = url => { + if (!url) return [] + // only the path is resolved server side, so scheme/host placeholders are ignored here too + const withoutQuery = String(url).split('#')[0].split('?')[0] + const withoutScheme = withoutQuery.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') + const slashIndex = withoutScheme.indexOf('/') + const path = slashIndex === -1 ? '' : withoutScheme.slice(slashIndex) + if (!path) return [] + const regex = new RegExp(PLACEHOLDER_PATTERN, 'g') + const names = [] + let match = regex.exec(path) + while (match !== null) { + if (match[1] !== '$' && !names.includes(match[2])) names.push(match[2]) + match = regex.exec(path) + } + return names +} + +/** + * Keeps `pathParams` in sync with the variables currently written in the url. + * + * Lives outside the component on purpose: the tab panel holding is + * unmounted while another tab is open, so syncing from there would leave the + * variables unmapped whenever the tab is never visited. + */ +export const usePathParamsSync = (webHooks, setWebHooks, enabled = true) => { + const placeholders = useMemo(() => getPathPlaceholders(webHooks?.url), [webHooks?.url]) + + useEffect(() => { + if (!enabled) return + + const existing = Array.isArray(webHooks?.pathParams) ? webHooks.pathParams : [] + const synced = placeholders.map( + key => existing.find(param => param?.key === key) || { key, value: '' } + ) + const isSame = + existing.length === synced.length && synced.every((param, i) => existing[i]?.key === param.key) + + if (!isSame) setWebHooks({ ...webHooks, pathParams: synced }) + }, [placeholders, webHooks?.pathParams, enabled]) + + return placeholders +} + +function PathParams({ webHooks, setWebHooks, isInfo, setTab }) { + useEffect(() => { + setTab(2) + }, []) + + const placeholders = useMemo(() => getPathPlaceholders(webHooks?.url), [webHooks?.url]) + + const paramValue = key => + (Array.isArray(webHooks?.pathParams) ? webHooks.pathParams : []).find(param => param?.key === key) + ?.value || '' + + const setParamValue = (key, value) => { + const existing = Array.isArray(webHooks?.pathParams) ? webHooks.pathParams : [] + const pathParams = existing.some(param => param?.key === key) + ? existing.map(param => (param?.key === key ? { ...param, value } : param)) + : [...existing, { key, value }] + + setWebHooks({ ...webHooks, pathParams }) + } + + const note = `${__( + 'Write a variable like {id} anywhere in the url path (e.g. https://api.example.com/v1/users/{id}/orders) and map it to a trigger field here. Values are url-encoded before the request, so a mapped value can never add extra path segments. If a mapped value is empty at run time the request is skipped and an error is logged.', + 'bit-integrations' + )}` + + return ( +
+
{__('Url Path Variables:', 'bit-integrations')}
+ + {placeholders.length === 0 ? ( + + ) : ( +
+
+
+
{__('Variable', 'bit-integrations')}
+
{__('Value', 'bit-integrations')}
+
+ {placeholders.map((key, childindx) => ( +
+
+ +
+
+ setParamValue(key, e.target.value)} + type="text" + value={paramValue(key)} + disabled={isInfo} + /> +
+ {!isInfo && ( +
+ + +
+ )} +
+ ))} +
+
+ )} +
+ ) +} + +export default PathParams diff --git a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/RequestHeaders.jsx b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/RequestHeaders.jsx index 67f747a01..4e9a70c99 100644 --- a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/RequestHeaders.jsx +++ b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/RequestHeaders.jsx @@ -1,13 +1,14 @@ import React, { useEffect } from 'react' -import MultiSelect from 'react-multiple-select-dropdown-lite' import CloseIcn from '../../../../Icons/CloseIcn' import TrashIcn from '../../../../Icons/TrashIcn' import { __ } from '../../../../Utils/i18nwrap' import Button from '../../../Utilities/Button' +import FlowFormFieldsOptions from '../FlowFormFieldsOptions' +import SmartTagOptions from '../SmartTagOptions' -function RequestHeaders({ formFields, webHooks, setWebHooks, isInfo, setTab }) { +function RequestHeaders({ webHooks, setWebHooks, isInfo, setTab }) { useEffect(() => { - setTab(2) + setTab(3) }, []) const handleHeader = (e, index) => { @@ -70,13 +71,15 @@ function RequestHeaders({ formFields, webHooks, setWebHooks, isInfo, setTab }) { - ({ label: f.label, value: `\${${f.name}}` }))} - className="btcd-paper-drpdwn wdt-200 ml-2" - singleSelect - onChange={val => setFromField(val, childindx)} - defaultValue={itm.value} - /> +
)}
diff --git a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/WebHooksIntegration.jsx b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/WebHooksIntegration.jsx index 741df9ef2..4f572c3e5 100644 --- a/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/WebHooksIntegration.jsx +++ b/frontend/src/components/AllIntegrations/IntegrationHelpers/WebHook/WebHooksIntegration.jsx @@ -6,6 +6,7 @@ import { __ } from '../../../../Utils/i18nwrap' import Button from '../../../Utilities/Button' import LoaderSm from '../../../Loaders/LoaderSm' import Params from './Params' +import PathParams, { usePathParamsSync } from './PathParams' import RequestHeaders from './RequestHeaders' import Body from './Body' import TableCheckBox from '../../../Utilities/TableCheckBox' @@ -25,6 +26,8 @@ export default function WebHooksIntegration({ }) { const [isLoading, setIsLoading] = useState(false) const [tab, setTab] = useState(1) + // runs here, not in , so the mapping exists even if that tab is never opened + usePathParamsSync(webHooks, setWebHooks, !isInfo) const method = ['GET', 'POST', 'PUT', 'PATCH', 'OPTION', 'DELETE', 'TRACE', 'CONNECT'] const handleInput = e => { const tmpConfConf = { ...webHooks } @@ -162,21 +165,28 @@ export default function WebHooksIntegration({ + + +
- + + + @@ -185,7 +195,6 @@ export default function WebHooksIntegration({ diff --git a/frontend/src/components/AllIntegrations/IvyForms/IvyFormsAuthorization.jsx b/frontend/src/components/AllIntegrations/IvyForms/IvyFormsAuthorization.jsx index e369d5e9a..35b2ffdda 100644 --- a/frontend/src/components/AllIntegrations/IvyForms/IvyFormsAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/IvyForms/IvyFormsAuthorization.jsx @@ -16,6 +16,7 @@ export default function IvyFormsAuthorization({ step={step} setStep={nextPage} isInfo={isInfo} + tutorialLinkKey="ivyForms" authDetails={{ authType: AUTH_TYPES.WP_PLUGIN_CHECK, pluginCheck: { diff --git a/frontend/src/components/AllIntegrations/MainWP/MainWPAuthorization.jsx b/frontend/src/components/AllIntegrations/MainWP/MainWPAuthorization.jsx index ba5b5dd4d..de84e2ef8 100644 --- a/frontend/src/components/AllIntegrations/MainWP/MainWPAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/MainWP/MainWPAuthorization.jsx @@ -10,6 +10,7 @@ export default function MainWPAuthorization({ mainWPConf, setMainWPConf, step, n step={step} setStep={nextPage} isInfo={isInfo} + tutorialLinkKey="mainWP" authDetails={{ authType: AUTH_TYPES.WP_PLUGIN_CHECK, pluginCheck: { diff --git a/frontend/src/components/AllIntegrations/MoreConvertWishlist/MoreConvertWishlistAuthorization.jsx b/frontend/src/components/AllIntegrations/MoreConvertWishlist/MoreConvertWishlistAuthorization.jsx index 0250f94f6..0265089ad 100644 --- a/frontend/src/components/AllIntegrations/MoreConvertWishlist/MoreConvertWishlistAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/MoreConvertWishlist/MoreConvertWishlistAuthorization.jsx @@ -16,6 +16,7 @@ export default function MoreConvertWishlistAuthorization({ step={step} setStep={nextPage} isInfo={isInfo} + tutorialLinkKey="moreConvertWishlist" authDetails={{ authType: AUTH_TYPES.WP_PLUGIN_CHECK, pluginCheck: { diff --git a/frontend/src/components/AllIntegrations/SecureCustomFields/SecureCustomFieldsAuthorization.jsx b/frontend/src/components/AllIntegrations/SecureCustomFields/SecureCustomFieldsAuthorization.jsx index 08a00912d..bfeecf696 100644 --- a/frontend/src/components/AllIntegrations/SecureCustomFields/SecureCustomFieldsAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/SecureCustomFields/SecureCustomFieldsAuthorization.jsx @@ -16,6 +16,7 @@ export default function SecureCustomFieldsAuthorization({ step={step} setStep={nextPage} isInfo={isInfo} + tutorialLinkKey="secureCustomFields" authDetails={{ authType: AUTH_TYPES.WP_PLUGIN_CHECK, pluginCheck: { diff --git a/frontend/src/components/AllIntegrations/WebbaBooking/WebbaBookingAuthorization.jsx b/frontend/src/components/AllIntegrations/WebbaBooking/WebbaBookingAuthorization.jsx index a7dfed439..5f97a542f 100644 --- a/frontend/src/components/AllIntegrations/WebbaBooking/WebbaBookingAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/WebbaBooking/WebbaBookingAuthorization.jsx @@ -16,6 +16,7 @@ export default function WebbaBookingAuthorization({ step={step} setStep={nextPage} isInfo={isInfo} + tutorialLinkKey="webbaBooking" authDetails={{ authType: AUTH_TYPES.WP_PLUGIN_CHECK, pluginCheck: { diff --git a/frontend/src/components/AllIntegrations/WordPress/WordPressAuthorization.jsx b/frontend/src/components/AllIntegrations/WordPress/WordPressAuthorization.jsx index 5b25e1160..6bd910c86 100644 --- a/frontend/src/components/AllIntegrations/WordPress/WordPressAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/WordPress/WordPressAuthorization.jsx @@ -16,6 +16,7 @@ export default function WordPressAuthorization({ step={step} setStep={nextPage} isInfo={isInfo} + tutorialLinkKey="wordPress" authDetails={{ authType: AUTH_TYPES.WP_PLUGIN_CHECK, pluginCheck: { diff --git a/frontend/src/components/Connections/Authorization.jsx b/frontend/src/components/Connections/Authorization.jsx index 5fbf2dc90..f095dadc6 100644 --- a/frontend/src/components/Connections/Authorization.jsx +++ b/frontend/src/components/Connections/Authorization.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import BackIcn from '../../Icons/BackIcn' import { isWpPluginCheckType } from '../../Utils/connectionAuth' import { verifyPluginActivation, listConnections } from '../../Utils/connectionApi' @@ -8,10 +8,14 @@ import Note from '../Utilities/Note' import TutorialLink from '../Utilities/TutorialLink' import AddNewConnection from './AddNewConnection' import ConnectionAccountSelect from './ConnectionAccountSelect' +import ConnectionNotice from './ConnectionNotice' +import { useConnectionSwitch } from './ConnectionSwitchContext' const STEP_ONE_STYLE = { width: 900, height: 'auto' } const ERROR_TEXT_STYLE = { color: 'red', fontSize: '15px' } +const omitConnectionId = ({ connection_id, ...rest }) => rest + export default function Authorization({ config, setConfig, @@ -30,11 +34,32 @@ export default function Authorization({ const [errors, setErrors] = useState({ name: '' }) const [connections, setConnections] = useState([]) const [showNewConnection, setShowNewConnection] = useState(false) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) const [isVerifying, setIsVerifying] = useState(false) const [isVerified, setIsVerified] = useState(false) const [isPendingPostAuth, setIsPendingPostAuth] = useState(false) + const connectionSwitch = useConnectionSwitch() + // Read-only page, but the connection stays changeable — including creating a + // new one and pointing the saved flow at it. + const canSwitch = Boolean(isInfo && connectionSwitch?.enabled) + // The info page holds no integration setter, so the sub-form's config writes + // (extra auth fields some actions persist beside connection_id) are collected + // here and handed to the flow update instead of being dropped. + const pendingConfig = useRef({}) + + const captureConfig = useCallback( + updater => { + const next = + typeof updater === 'function' + ? updater({ ...(config || {}), ...pendingConfig.current }) + : updater + + pendingConfig.current = { ...pendingConfig.current, ...(next || {}) } + }, + [config] + ) + const appSlug = config?.app_slug || config?.type const isWpPluginCheck = isWpPluginCheckType(authDetails?.authType) const resolvedTutorialLinkKey = tutorialLinkKey || tutorialTitle @@ -50,6 +75,7 @@ export default function Authorization({ if (!appSlug) { setConnections([]) setShowNewConnection(true) + setIsLoading(false) return [] } @@ -60,17 +86,20 @@ export default function Authorization({ const savedConnections = res?.success && Array.isArray(res?.data?.data) ? res.data.data : [] setConnections(savedConnections) - setShowNewConnection(current => current || savedConnections.length === 0) + // An empty list opens the create form on the wizard steps, but the info + // page must stay quiet until the user asks for a new connection. + setShowNewConnection(current => current || (savedConnections.length === 0 && !isInfo)) return savedConnections } catch { return [] } finally { setIsLoading(false) } - }, [appSlug]) + }, [appSlug, isInfo]) useEffect(() => { if (isWpPluginCheck) { + setIsLoading(false) return } @@ -165,6 +194,16 @@ export default function Authorization({ const refreshedConnections = await refreshConnections() const savedConnectionId = savedConnection?.id + if (canSwitch) { + if (savedConnectionId) { + const capturedConfig = omitConnectionId(pendingConfig.current) + pendingConfig.current = {} + setShowNewConnection(false) + await connectionSwitch.onSwitch(savedConnectionId, capturedConfig) + } + return + } + if (savedConnectionId) { const matchedConnection = refreshedConnections.find( conn => String(conn.id) === String(savedConnectionId) @@ -180,7 +219,7 @@ export default function Authorization({ setShowNewConnection(false) } }, - [refreshConnections, setConfig, fireConnectionSelected] + [canSwitch, connectionSwitch, refreshConnections, setConfig, fireConnectionSelected] ) return ( @@ -210,24 +249,28 @@ export default function Authorization({ setConfig={setConfig} connections={connections} setShowNewConnection={setShowNewConnection} - isInfo={isInfo || isLoading} + isInfo={isInfo} onRefresh={refreshConnections} isRefreshing={isLoading} onConnectionSelected={fireConnectionSelected} /> - {showNewConnection && !isInfo && (extraFields || null)} + {showNewConnection && (!isInfo || canSwitch) && (extraFields || null)} - {showNewConnection && !isInfo && ( + {showNewConnection && (!isInfo || canSwitch) && ( )} + + {isInfo && !config?.connection_id && ( + + )} )} @@ -248,6 +291,18 @@ export default function Authorization({ )} + + {/* Switched connection: the field mapping still targets the old account, + so hand the user off to the edit wizard to review it. */} + {canSwitch && connectionSwitch.switched && ( + + )}

diff --git a/frontend/src/components/Connections/ConnectionAccountSelect.jsx b/frontend/src/components/Connections/ConnectionAccountSelect.jsx index d1e83c1c6..eda736577 100644 --- a/frontend/src/components/Connections/ConnectionAccountSelect.jsx +++ b/frontend/src/components/Connections/ConnectionAccountSelect.jsx @@ -2,9 +2,13 @@ import { useCallback, useMemo } from 'react' import MultiSelect from 'react-multiple-select-dropdown-lite' import { useParams } from 'react-router' import { __ } from '../../Utils/i18nwrap' +import LoaderSm from '../Loaders/LoaderSm' +import { useConnectionSwitch } from './ConnectionSwitchContext' import 'react-multiple-select-dropdown-lite/dist/index.css' const NEW_VALUE = '__new__' +const HINT_TEXT_STYLE = { opacity: 0.7 } +const CONTROL_ROW_STYLE = { gap: 8, alignItems: 'center', flexWrap: 'wrap' } const buildConnectionOption = conn => { const accountName = conn.account_name || conn.connection_name @@ -38,6 +42,11 @@ export default function ConnectionAccountSelect({ onConnectionSelected }) { const { integUrlName } = useParams() + const connectionSwitch = useConnectionSwitch() + // On the info page the whole form is read-only, but the connection itself + // stays swappable — the provider persists the pick straight to the flow. + const canSwitch = Boolean(isInfo && connectionSwitch?.enabled) + const isSwitching = Boolean(connectionSwitch?.isSwitching) const dropdownValue = getConnectionOptionById(connections, config?.connection_id) const options = useMemo( @@ -50,6 +59,22 @@ export default function ConnectionAccountSelect({ const handleChange = useCallback( value => { + if (canSwitch) { + if (!value) return + + // The form below handles creation; the flow is switched once it saves. + if (value === NEW_VALUE) { + setShowNewConnection(true) + return + } + + setShowNewConnection(false) + if (String(value) === String(config?.connection_id)) return + + connectionSwitch.onSwitch(value) + return + } + const isNewConnection = value === NEW_VALUE setShowNewConnection(isNewConnection) @@ -61,7 +86,14 @@ export default function ConnectionAccountSelect({ setConfig(prev => ({ ...prev, connection_id: value })) onConnectionSelected?.(value) }, - [setConfig, setShowNewConnection, onConnectionSelected] + [ + canSwitch, + connectionSwitch, + config?.connection_id, + setConfig, + setShowNewConnection, + onConnectionSelected + ] ) const connectionTitle = integUrlName @@ -75,7 +107,7 @@ export default function ConnectionAccountSelect({
{connectionTitle}
-
+
- {!isInfo && fetchConnections && ( + {isSwitching && } + {(canSwitch || !isInfo) && fetchConnections && ( )} + {isLoading && ( + + {__('Loading connections...', 'bit-integrations')} + + )}
+ {canSwitch && ( +
+ {connectionSwitch.switched + ? __( + 'Connection updated. Continue to review the integration config against the new account.', + 'bit-integrations' + ) + : __( + 'Pick another connection or add a new one — this integration is updated instantly.', + 'bit-integrations' + )} +
+ )}
) } diff --git a/frontend/src/components/Connections/ConnectionNotice.jsx b/frontend/src/components/Connections/ConnectionNotice.jsx new file mode 100644 index 000000000..50733decb --- /dev/null +++ b/frontend/src/components/Connections/ConnectionNotice.jsx @@ -0,0 +1,70 @@ +import { Link } from 'react-router' +import InfoIcn from '../../Icons/InfoIcn' +import { __ } from '../../Utils/i18nwrap' + +/** + * Read-only explainer for the integration info page. + * + * `isLegacy` covers integrations saved before 2.10.0: they keep their credentials + * inline in flow_details instead of pointing at a connection row, so the info page + * has nothing to show them and no connection to switch. + */ +export default function ConnectionNotice({ onOpenSettings }) { + return ( +
+ + +
+

+ {__('Why the API credentials are not shown here', 'bit-integrations')} +

+

+ {__( + 'This integration was set up before version 2.10.0, so its credentials are stored with the integration itself instead of in a saved connection. This screen only displays saved connections, so there is nothing here for it to show.', + 'bit-integrations' + )} +

+

+ {__( + 'Saved credentials are never sent back to your browser. They stay encrypted on your server, so an API key or token cannot leak through a screenshot, a shared screen, browser history or a hijacked admin session. The fields look empty because the credentials are protected, not because they are missing.', + 'bit-integrations' + )} +

+

+ {__( + 'This integration keeps running exactly as before. Nothing is broken.', + 'bit-integrations' + )} +

+

+ {__( + 'Want it on the new system? Open the integration settings and authorize this app once. It then starts using a saved connection you can reuse for every future integration.', + 'bit-integrations' + )} +

+ +
+

+ {__('Authorize once, reuse everywhere', 'bit-integrations')} +

+

+ {__( + 'Connections are the new home for credentials. Authorize an app once and every integration for that same app can pick the same connection — no re-entering API keys, no repeating the OAuth flow.', + 'bit-integrations' + )} +

+

+ {__( + 'Rename, review or remove connections any time from the Connections page. Updating a connection updates every integration linked to it.', + 'bit-integrations' + )} +

+ + {__('Manage connections', 'bit-integrations')} + +
+
+ ) +} diff --git a/frontend/src/components/Connections/ConnectionSwitchContext.jsx b/frontend/src/components/Connections/ConnectionSwitchContext.jsx new file mode 100644 index 000000000..3a898e5d1 --- /dev/null +++ b/frontend/src/components/Connections/ConnectionSwitchContext.jsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react' + +// Lets a read-only (isInfo) integration page turn the connection dropdown back +// on without every integration's Authorization wrapper forwarding a new prop. +// The provider owns the persistence (flow/update); ConnectionAccountSelect only +// reports which connection was picked. +const ConnectionSwitchContext = createContext(null) + +export const ConnectionSwitchProvider = ConnectionSwitchContext.Provider + +export const useConnectionSwitch = () => useContext(ConnectionSwitchContext) diff --git a/frontend/src/components/Utilities/Table/BulkActionsMenu.jsx b/frontend/src/components/Utilities/Table/BulkActionsMenu.jsx index 5ec9a1264..f58a0ee71 100644 --- a/frontend/src/components/Utilities/Table/BulkActionsMenu.jsx +++ b/frontend/src/components/Utilities/Table/BulkActionsMenu.jsx @@ -65,61 +65,61 @@ function BulkActionsMenu({ - {isOpen && ( -
- {onBulkTagAssign && ( - - )} +
+ {onBulkTagAssign && ( + + )} - {onBulkDelete && ( - - )} + {onBulkDelete && ( + + )} - {onBulkStatus && ( - - )} + {onBulkStatus && ( + + )} - {onBulkDuplicate && ( - - )} -
- )} + {onBulkDuplicate && ( + + )} +
) diff --git a/frontend/src/components/Utilities/TutorialLink.jsx b/frontend/src/components/Utilities/TutorialLink.jsx index 42954539e..8c25756e6 100644 --- a/frontend/src/components/Utilities/TutorialLink.jsx +++ b/frontend/src/components/Utilities/TutorialLink.jsx @@ -123,30 +123,30 @@ function TutorialLink({ subtitle, linkKey, style, linksMap }) { {__('Summarize with AI', 'bit-integrations')} - {showAiTools && ( -
-

- {__('Choose your AI assistant', 'bit-integrations')} -

-
- {aiTools.map(tool => ( - setShowAiTools(false)}> - - - - {tool.name} - - ))} -
+
+

+ {__('Choose your AI assistant', 'bit-integrations')} +

+
+ {aiTools.map(tool => ( + setShowAiTools(false)}> + + + + {tool.name} + + ))}
- )} +
)} {subtitle &&

{subtitle}

} diff --git a/frontend/src/pages/ChangelogToggle.jsx b/frontend/src/pages/ChangelogToggle.jsx index c68d19ba3..8bb88281e 100644 --- a/frontend/src/pages/ChangelogToggle.jsx +++ b/frontend/src/pages/ChangelogToggle.jsx @@ -23,19 +23,52 @@ const changeLog = [ label: __('Note', 'bit-integrations'), headClass: 'new-note', itemClass: '', - items: [] + items: [ + { + label: 'Since 2.10.0, credentials live in reusable Connections. Integrations you set up before that still keep their own credentials, so we call them legacy - their info page shows a short explainer instead of a connection to view or switch.', + desc: '', + isPro: false + }, + { + label: 'They keep working exactly as before, and credentials stay safe on your server (never sent to your browser, which is why the fields look empty). To move one over, just open its settings and authorize the app once.', + desc: '', + isPro: false + } + ] }, { label: __('New Triggers', 'bit-integrations'), headClass: 'new-trigger', itemClass: 'integration-list', - items: [] + items: [ + { + label: 'Bit CRM', + desc: '66 new events added', + isPro: false + }, + { + label: 'Fluent Player', + desc: '12 new events added', + isPro: true + } + ] }, { label: __('New Actions', 'bit-integrations'), headClass: 'new-integration', itemClass: 'integration-list', - items: [] + items: [ + { + label: 'Bit CRM', + desc: '50 new events added', + isPro: false + }, + { + label: 'Fluent Player', + desc: '26 new events added', + isPro: true + } + ] }, { label: __('New Features', 'bit-integrations'), @@ -43,18 +76,18 @@ const changeLog = [ itemClass: 'feature-list', items: [ { - label: 'Connections', - desc: 'New centralized connection manager - authorize an app once and reuse the same credentials across every integration, with linked integration listing, inline editing and clearer authorization errors.', + label: 'Webhook (Action)', + desc: 'Dynamic URL path variables added to outgoing webhooks, mappable from trigger data.', isPro: false }, { - label: 'Timeline', - desc: 'Integration logs can now be re-executed, with nested re-run history kept for each attempt.', - isPro: false + label: 'Webhook (Action)', + desc: 'Smart codes are now available in query parameters, request headers and path variables.', + isPro: true }, { label: 'Connections', - desc: 'Redesigned log page with server-side status filtering, search and column controls.', + desc: "An action's connection can now be switched from its info page.", isPro: false } ] @@ -71,19 +104,29 @@ const changeLog = [ itemClass: 'fixes-list', items: [ { - label: 'OAuth2', - desc: 'Fixed refresh URL template resolution and credentials being lost during token refresh.', + label: 'Connections', + desc: 'Fixed credentials not resolving for renamed integrations.', isPro: false }, { - label: 'Google Sheets', - desc: 'Improved access token validation.', + label: 'Integration Info', + desc: 'Fixed legacy actions rendering a blank info page.', isPro: false }, { - label: 'Zoho Desk', - desc: 'Guarded against null API response data.', + label: 'Webhook (Action)', + desc: 'Run status is now judged by the response HTTP status code, and path variables sync even when their tab is never opened.', isPro: false + }, + { + label: 'ACF', + desc: 'Fixed field reading when the meta value is missing', + isPro: true + }, + { + label: 'WP Post', + desc: 'Fixed trashed and internal posts firing the post created/inserted triggers', + isPro: true } ] }, @@ -93,18 +136,18 @@ const changeLog = [ itemClass: 'fixes-list', items: [ { - label: 'Security', - desc: 'Hardened AJAX authorization, credential storage and input handling across request routing.', + label: 'Custom Action', + desc: "Closed an administrator gate bypass and confined the custom function file to the plugin's custom-function directory.", isPro: false }, { - label: 'Connections', - desc: 'Stopped credentials from being returned in connection responses and made the token refresh lock reliable.', + label: 'Timeline', + desc: 'Log re-execution now applies the same administrator check as custom action save, update and delete.', isPro: false }, { - label: 'Encryption', - desc: 'Escaped OpenSSL error output in encryption exception handling.', + label: 'Mail', + desc: 'Recipient addresses and headers are validated in all cases, and header display names are sanitized.', isPro: false } ] diff --git a/frontend/src/resource/sass/app.scss b/frontend/src/resource/sass/app.scss index 360607a7f..1ff3ecdd3 100644 --- a/frontend/src/resource/sass/app.scss +++ b/frontend/src/resource/sass/app.scss @@ -34,28 +34,19 @@ line-height: 1; } -//$blue: #0a8dff; -//$blue: #0083f3; -// $dp-purple: #0b1655; -//$light-bg: #f2f3f7; -//$light-bg: #eaf0f7; -// $bg: #f2f3f7; $purple: #7902f8; -$light-purple: #6c11f9; -// $purple: #7f57ef; -$bdr-rad: 0px; $prim: #99e3ff; $blue: #0069ff; -$green: #36fcb3; $light-purple-bg: #371f4a; $dp-purple: #391794; $dp-bg: #2a054f; $light-bg: #fff; $light-txt: #8fa4bd; -$bg: #f7f8fc; $red: #ff4646; $dp-txt: #545582; $gray: #e2e2e2; +// Page canvas: every route shell, page and table surface sits on this. +$pg-canvas: #f8f9fd; $select-arrow: "data:image/svg+xml;charset=utf8,%3Csvg width='20' height='20' xmlns='http://www.w3.org/2000/svg'%3E%3C!-- Created with Method Draw - http://github.com/duopixel/Method-Draw/ --%3E%3Cg%3E%3Ctitle%3Ebackground%3C/title%3E%3Crect fill='none' id='canvas_background' height='22' width='22' y='-1' x='-1'/%3E%3Cg display='none' overflow='visible' y='0' x='0' height='100%25' width='100%25' id='canvasGrid'%3E%3Crect fill='url(%23gridpattern)' stroke-width='0' y='0' x='0' height='100%25' width='100%25'/%3E%3C/g%3E%3C/g%3E%3Cg%3E%3Ctitle%3ELayer 1%3C/title%3E%3Cline transform='rotate(45 9.707600593566898,10.544168472290037) ' stroke-linecap='undefined' stroke-linejoin='undefined' id='svg_2' y2='14.860267' x2='9.7076' y1='6.228069' x1='9.7076' stroke-width='1.5' stroke='%238c8c8c' fill='none'/%3E%3Cline transform='rotate(-45 4.444442749023432,10.544166564941404) ' stroke-linecap='undefined' stroke-linejoin='undefined' id='svg_1' y2='14.860266' x2='4.444443' y1='6.228067' x1='4.444443' stroke-width='1.5' stroke='%238c8c8c' fill='none'/%3E%3C/g%3E%3C/svg%3E"; html, @@ -84,7 +75,7 @@ a:hover { min-height: 82vh; height: auto; font-size: 16px; - background-color: #f5f8ff; + background-color: $pg-canvas; & a { text-decoration: none; @@ -264,19 +255,6 @@ input[type='checkbox'].disabled:checked:before { .font-sm { font-size: 14px !important; } - -.txt-white { - color: white; -} - -.txt-gray { - color: $gray; -} - -.txt-blue { - color: $blue; -} - .txt-purple { color: $purple; } @@ -284,13 +262,6 @@ input[type='checkbox'].disabled:checked:before { .txt-pro { color: rgb(255, 255, 255); } - -.bg-pro { - background: lightgoldenrodyellow; - padding: 20px; - border-radius: 8px; -} - .txt-dp { color: $dp-txt; } @@ -370,7 +341,6 @@ input[type='checkbox'].disabled:checked:before { -webkit-user-select: none; -webkit-user-drag: none; user-select: none; - // user-drag: none; pointer-events: none; } @@ -426,17 +396,14 @@ input[type='checkbox'].disabled:checked:before { } .blue { - //background: $blue !important; background: linear-gradient(145deg, $blue, #097fe6) !important; color: white !important; &:hover { - //background: #016fcf !important; background: linear-gradient(145deg, darken($blue, 2), darken($blue, 5)) !important; } &:active { - // background: #0062b8; background: linear-gradient(145deg, darken($blue, 4), darken($blue, 7)) !important; } } @@ -455,33 +422,27 @@ input[type='checkbox'].disabled:checked:before { } .purple { - //background: $purple !important; background: linear-gradient(145deg, $purple, #6049f2) !important; color: white !important; &:hover { - //background: #016fcf !important; background: linear-gradient(145deg, darken($purple, 2), darken($purple, 5)) !important; } &:active { - // background: #0062b8; background: linear-gradient(145deg, darken($purple, 4), darken($purple, 7)) !important; } } .gray { - //background: $purple !important; background: linear-gradient(145deg, $gray, #e1dcfc) !important; color: rgb(94, 88, 88) !important; &:hover { - //background: #016fcf !important; background: linear-gradient(145deg, darken($gray, 2), darken($gray, 5)) !important; } &:active { - // background: #0062b8; background: linear-gradient(145deg, darken($gray, 4), darken($gray, 7)) !important; } } @@ -523,15 +484,6 @@ input[type='checkbox'].disabled:checked:before { background: rgb(238, 240, 255) !important; } } - -.bg-blue-1 { - background: transparentize($blue, 0.9) !important; -} - -.bg-blue-2 { - background: transparentize($blue, 0.7) !important; -} - .blue-sh { box-shadow: 3px 3px 7px -2px rgba(73, 89, 255, 0.4); transition: box-shadow 0.3s !important; @@ -558,16 +510,6 @@ input[type='checkbox'].disabled:checked:before { box-shadow: 2px 2px 4px 0 rgba(229, 219, 253, 0.4); } } - -.red-sh { - box-shadow: 3px 3px 12px 0px transparentize($red, 0.7); - transition: box-shadow 0.2s; - - &:hover { - box-shadow: 2px 2px 4px 0 transparentize($red, 0.7); - } -} - #content { width: 100%; } @@ -608,6 +550,148 @@ input[type='checkbox'].disabled:checked:before { } } +// Explainer on the read-only integration info page: how reusable connections work, +// and why a pre-2.10.0 integration shows no credentials. +.conn-notice { + display: flex; + gap: 5px; + clear: both; + margin: 20px 0; + max-width: 640px; + padding: 14px 14px 16px 5px; + border: 1px solid #dde5f4; + border-left: 3px solid $purple; + border-radius: 8px; + background: $pg-canvas; + color: #4a5673; + font-size: 13px; + line-height: 1.55; + animation: fadeIn 180ms ease-out both; + + .conn-notice-icn { + flex: 0 0 auto; + margin-top: 1px; + color: $purple; + line-height: 0; + } + + .conn-notice-body { + min-width: 0; + } + + .conn-notice-title { + margin: 0 0 6px; + color: #2c3654; + font-size: 13.5px; + font-weight: 600; + line-height: 1.4; + } + + .conn-notice-sep { + height: 0; + margin: 16px 0; + border: 0; + border-top: 1px solid currentcolor; + opacity: 0.18; + } + + p { + margin: 0 0 8px; + } + + p:last-of-type { + margin-bottom: 0; + } + + .conn-notice-link { + display: inline-flex; + align-items: center; + margin-top: 14px; + padding: 9px 15px; + border: 0; + border-radius: 8px; + background: $purple; + color: #fff; + font-size: 12.5px; + font-weight: 600; + line-height: 1; + text-decoration: none; + cursor: pointer; + transition: + background-color 160ms cubic-bezier(0.23, 1, 0.32, 1), + box-shadow 160ms cubic-bezier(0.23, 1, 0.32, 1), + transform 160ms cubic-bezier(0.23, 1, 0.32, 1); + + &:hover, + &:focus-visible { + background: darken($purple, 10); + color: #fff; + box-shadow: 0 2px 8px rgba(151, 54, 255, 0.28); + } + + &:focus-visible { + outline: 2px solid $purple; + outline-offset: 2px; + } + + &:active { + transform: scale(0.97); + box-shadow: none; + } + } + + &.conn-notice-legacy { + border-color: #f4d8b8; + border-left-color: #d99326; + background: #fffaf3; + color: #6b5432; + + .conn-notice-icn { + color: #b3721f; + } + + .conn-notice-title { + color: #7a4f12; + } + + .conn-notice-link { + background: #8f5c21; + color: #fff; + + &:hover, + &:focus-visible { + background: #6b4315; + color: #fff; + box-shadow: 0 2px 8px rgba(143, 92, 33, 0.3); + } + + &:focus-visible { + outline-color: #8f5c21; + } + + &:active { + box-shadow: none; + } + } + } +} + +@media only screen and (max-width: 767px) { + .conn-notice { + max-width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .conn-notice { + animation: none; + + .conn-notice-link:active { + transform: none; + } + } +} + .f-right { float: right; } @@ -621,13 +705,6 @@ input[type='checkbox'].disabled:checked:before { width: 150px; height: 200px; } - -.layout-wrapper { - margin: auto; - margin-top: 3px; - height: calc(100% - 40px); -} - .isDragging { box-shadow: inset 0 0 4px 1px #2c9eff; } @@ -636,59 +713,6 @@ input[type='checkbox'].disabled:checked:before { overflow: hidden; min-height: 100px !important; } - -/* ._frm-bg { - overflow: auto; -} */ - -.toolBar-wrp { - background: $bg; - height: 100%; - overflow: hidden; - max-width: 165px; - min-width: 55px; - padding: 5px; - padding-bottom: 35px; - transition: width 500ms; - - & > h4 { - width: 100%; - margin: 10px 0; - text-align: center; - } -} - -.btcd-toolbar-title { - display: flex; - white-space: nowrap; - word-break: keep-all; - -webkit-user-select: none; - -ms-user-select: none; - -webkit-user-drag: none; - justify-content: space-between; - align-items: center; - margin: 5px 0 10px 12px; - font-weight: bold; - - & > button { - background: $bg; - color: #73818e; - width: 25px; - height: 25px; - font-weight: bold; - - &:hover { - background: $bg; - } - } -} - -.toolBar { - display: flex; - padding-bottom: 80px; - flex-wrap: wrap; -} - .tools { display: flex; background: white; @@ -728,22 +752,6 @@ input[type='checkbox'].disabled:checked:before { } } } - -.tool-img { - width: 21px; - min-width: 21px; - margin-right: 15px; -} - -.btcd-empty { - color: #d1d8ed; - - & > span { - display: block; - font-size: 50px; - } -} - .btcd-ck-wrp { display: inline-flex; color: rgb(54, 54, 54); @@ -768,13 +776,6 @@ input[type='checkbox'].disabled:checked:before { height: 0; width: 0; } - -.btcd-ck-con { - display: flex; - flex-wrap: wrap; - margin-top: 8px; -} - .btcd-mrk { position: absolute; top: 0; @@ -796,220 +797,6 @@ input[type='checkbox'].disabled:checked:before { background-color: $purple; border-color: $purple !important; } - -.btc-range { - & .icn { - & svg { - height: 13px; - width: 13px; - } - } - - & .inp-grp { - &:hover { - .icn { - color: $purple; - background-color: transparentize($purple, 0.92); - - & .line-icn { - stroke: $purple; - } - - & .fill-icn { - fill: $purple; - } - - .border-icn { - border-color: $purple; - } - } - } - } - - & .icn { - background: rgb(238, 243, 250); - width: 30px; - max-width: 30px; - min-width: 30px; - max-height: 30px; - height: 30px; - justify-content: center; - } - - & input[type='number'] { - width: 55px; - line-height: 1 !important; - outline: none; - border-radius: 5px; - border: 1px solid rgb(224, 224, 224); - font-size: 14px; - padding: 4px; - - &:hover, - :focus { - box-shadow: none; - border-color: gray; - } - } -} - -.btc-range { - $track-color: #eceff1 !default; - $thumb-color: transparentize($dp-purple, 0.2) !default; - - $thumb-radius: 12px !default; - $thumb-height: 13px !default; - $thumb-width: 13px !default; - $thumb-shadow-size: 2px !default; - $thumb-shadow-blur: 4px !default; - $thumb-shadow-color: rgba(0, 0, 0, 0.2) !default; - $thumb-border-width: 1.5px !default; - $thumb-border-color: #ff7777 !default; - - $track-width: 100% !default; - $track-height: 6px !default; - $track-shadow-size: 0px !default; - $track-shadow-blur: 0px !default; - $track-shadow-color: rgba(0, 0, 0, 0.2) !default; - $track-border-width: 0px !default; - $track-border-color: #dccfcf !default; - - $track-radius: 5px !default; - $contrast: 7% !default; - - $ie-bottom-track-color: darken($track-color, $contrast) !default; - - @mixin shadow($shadow-size, $shadow-blur, $shadow-color) { - box-shadow: - $shadow-size $shadow-size $shadow-blur $shadow-color, - 0 0 $shadow-size lighten($shadow-color, 5%); - } - - @mixin track { - cursor: default; - height: $track-height; - transition: all 0.2s ease; - width: $track-width; - } - - @mixin thumb { - background: $thumb-color; - border: $thumb-border-width solid $thumb-border-color; - border-radius: $thumb-radius; - box-sizing: border-box; - cursor: pointer; - height: $thumb-height; - width: $thumb-width; - @include shadow($thumb-shadow-size, $thumb-shadow-blur, $thumb-shadow-color); - } - - [type='range'] { - background: transparent; - -webkit-appearance: none; - margin: calc($thumb-height / 2) 0; - width: $track-width; - - &::-moz-focus-outer { - border: 0; - } - - &:hover { - &::-webkit-slider-runnable-track { - background: darken($track-color, $contrast - 1); - } - - &::-ms-fill-lower { - background: $track-color; - } - - &::-ms-fill-upper { - background: darken($track-color, $contrast - 1); - } - } - - &:focus { - outline: 0; - - &::-webkit-slider-runnable-track { - background: darken($track-color, $contrast); - } - - &::-ms-fill-lower { - background: $track-color; - } - - &::-ms-fill-upper { - background: darken($track-color, $contrast); - } - } - - &::-webkit-slider-runnable-track { - background: $track-color; - border: $track-border-width solid $track-border-color; - border-radius: $track-radius; - @include track; - @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color); - } - - &::-webkit-slider-thumb { - -webkit-appearance: none; - margin-top: calc(($track-border-width * 2 + $track-height) / 2 - ($thumb-height / 2)); - @include thumb; - } - - &::-moz-range-track { - background: $track-color; - border: $track-border-width solid $track-border-color; - border-radius: $track-radius; - height: calc($track-height / 2); - @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color); - @include track; - } - - &::-moz-range-thumb { - @include thumb; - } - - &::-ms-track { - background: transparent; - color: transparent; - border-color: transparent; - border-width: calc($thumb-height / 2) 0; - @include track; - } - - &::-ms-fill-lower { - background: $ie-bottom-track-color; - border: $track-border-width solid $track-border-color; - border-radius: calc($track-radius * 2); - @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color); - } - - &::-ms-fill-upper { - background: $track-color; - border: $track-border-width solid $track-border-color; - border-radius: calc($track-radius * 2); - @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color); - } - - &::-ms-thumb { - margin-top: calc($track-height / 4); - @include thumb; - } - - &:disabled { - &::-webkit-slider-thumb, - &::-moz-range-thumb, - &::-ms-thumb, - &::-webkit-slider-runnable-track, - &::-ms-fill-lower, - &::-ms-fill-upper { - cursor: not-allowed; - } - } - } -} - .btcd-mrk:after { display: none; position: absolute; @@ -1085,64 +872,9 @@ input[type='checkbox'].disabled:checked:before { color: #4e4e4e; } } - -.bar-l { - background: #f8f8f8; - width: 0; - pointer-events: none; - - &::before { - content: ''; - } - - &:hover { - width: 8px; - border-right: 1px solid #c7c7c7; - } +.ss-content.ss-open:parent { + background: red; } - -.bar-r { - background: $bg; - - &:hover { - width: 8px; - - & > div { - border-left: 3px solid $purple; - width: 12px; - } - } -} - -.ss-content.ss-open:parent { - background: red; -} - -.style-acc { - overflow: hidden; - margin: auto; - - & .btgl { - cursor: pointer; - padding: 10px; - font-size: 16 px; - outline: none; - font-weight: 600; - - & .btcd-icn { - font-size: 20px; - } - - &:hover { - background: transparentize($purple, 0.95); - } - } - - & .body { - padding: 10px; - } -} - .style-acc.active, .delay-overflow { overflow: visible; @@ -1154,149 +886,6 @@ input[type='checkbox'].disabled:checked:before { overflow: hidden; } } - -.btc-pick { - background: white; - position: absolute; - top: 10px; - right: -8px; - z-index: 99; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); - - & .ui-color-picker { - margin: 0; - - & .picker-area { - padding: 5px; - } - } - - & .gradient-controls { - height: 45px; - margin-bottom: 0px; - } -} - -.clr-pick { - cursor: pointer; - height: 30px; - width: 30px; - outline: none; - border: 2px solid white; - box-shadow: 0 0 0 2px $dp-txt; -} - -.btc-pk-enter { - transform: scale(0); - transition: transform 150ms; -} - -.btc-pk-enter-active { - transform: scale(1.03); - transition: transform 150ms; -} - -.btc-pk-enter-done { - position: relative; - z-index: 99; - transform: scale(1); - transition: transform 100ms; -} - -.btc-btn-grp { - & button:first-child { - border-radius: 7px 0 0 7px; - } - - & button { - background: transparent; - color: $dp-txt; - position: relative; - font-size: 13px; - border: 1px solid lightgray; - border-right: 0; - border-radius: 0; - outline: none; - padding: 3px 7px; - cursor: pointer; - - & .btcd-icn { - font-size: 15px; - } - } - - & button:last-child { - border-radius: 0 7px 7px 0; - border-right: 1px solid lightgray; - } - - & button.active { - background: $purple; - color: white; - border-color: $purple; - - & .line-icn { - stroke: white; - } - - & .fill-icn { - fill: white; - } - } - - & svg { - display: block; - height: 13px; - width: 13px; - - & .line-icn { - stroke: $dp-purple; - } - - & .fill-icn { - fill: $dp-purple; - } - } -} - -// components style -.com-wrp { - width: 100%; - height: 100%; - cursor: default; -} - -.blk-icn-wrp { - background: rgba(255, 255, 255, 0.658); - right: 0; - height: 0; - width: 0; - - & > button { - background: transparent; - color: $dp-purple; - width: 22px; - height: 22px; - border-radius: 3px; - border: none; - transform: scale(0); - transition: transform 300ms; - } - - & > button:hover { - background: rgb(241, 241, 241); - color: black; - } -} - -.fld-lbl { - cursor: default; - white-space: nowrap; - text-overflow: ellipsis; -} - .blk { outline: none; @@ -1323,38 +912,9 @@ input[type='checkbox'].disabled:checked:before { .mce-fullscreen { height: 100%; } - -.elm-settings { - background: $bg; - height: 100%; - max-width: 500px; - margin-right: 0; - margin-left: auto; - font-size: 18px; - - & > h4 { - margin: 0 0 0 10px; - padding-top: 10px; - padding-bottom: 10px; - border-bottom: 1px solid #c7c7c7; - } - - & > .settings { - font-size: 14px; - height: calc(100% - 85px); - } -} - -.elm-settings-title { - z-index: 9; - -webkit-clip-path: inset(-30px 0px); - clip-path: inset(-30px 0px); -} - .btcd-inte-wrp { align-items: baseline; justify-content: center; - //overflow-y: scroll; height: 90%; margin-bottom: 20px; width: 100%; @@ -1374,7 +934,6 @@ input[type='checkbox'].disabled:checked:before { border-radius: 15px; outline: none; border: 1px solid #dce7ff; - // box-shadow: 0px 2px 3px #d7e0ed; background-color: aliceblue; transition: box-shadow 0.2s, @@ -1473,9 +1032,6 @@ input[type='checkbox'].disabled:checked:before { max-width: 100px; max-height: 100px; object-fit: contain; - // margin: 0 auto; - // margin-top: 12px; - // padding: 12px; } &:before { @@ -1527,7 +1083,6 @@ input[type='checkbox'].disabled:checked:before { } .btcd-inte-pro { - // pointer-events: none; -ms-user-select: none; -webkit-user-select: none; user-select: none; @@ -1561,13 +1116,11 @@ input[type='checkbox'].disabled:checked:before { & .pro-filter { display: flex; - // background: #4e5a9447; position: absolute; text-align: right; align-items: right; justify-content: right; flex-direction: row; - // height: 100%; width: 100%; z-index: 9; top: 0; @@ -1588,21 +1141,6 @@ input[type='checkbox'].disabled:checked:before { } } } - -.setting-inp { - display: inline-flex; - flex-direction: column; - width: 100%; - - & span { - font-weight: 600; - } - - & input { - margin-top: 6px; - } -} - #modal-title-wrapper { display: flex; flex-direction: column; @@ -1675,19 +1213,6 @@ textarea:focus { box-shadow: none; } } - -.btcd-btn-o-blue { - background: transparent; - color: $blue !important; - border: 1px solid $blue; - transition: background 0.2s !important; - - &:hover { - background: $blue; - color: white !important; - } -} - .btcd-btn-o-purple { background: transparent; color: $purple !important; @@ -1699,24 +1224,6 @@ textarea:focus { color: white !important; } } - -.btcd-btn-purple-active { - background-color: $purple; - color: white; -} - -.btcd-btn-o-red { - background: transparent; - color: $red !important; - border: 1px solid $red; - transition: background 0.2s !important; - - &:hover { - background: $red; - color: white !important; - } -} - .btcd-btn-o-gray { background: transparent; color: #4b4b4b !important; @@ -1826,7 +1333,6 @@ button:disabled { background: rgb(195, 194, 194) !important; color: #ffffff; opacity: 50%; - // box-shadow: 0 0 0 1px $purple !important; outline: 2px solid transparent !important; } @@ -1837,25 +1343,6 @@ button:disabled { .round { border-radius: 50px; } - -.z-9 { - overflow: visible; - z-index: 9; -} - -.cls-btn { - background: #d8e0e8; - font-size: 25px; - padding: 1px; - width: 25px; - height: 25px; - line-height: 0px; - - &:hover { - color: red; - } -} - // navbar start nav.top-nav { background: white; @@ -1897,7 +1384,7 @@ nav.top-nav { } .route-wrp { - background: white; + background: $pg-canvas; min-height: 82vh; margin: -150px 15px 15px; border-radius: 20px; @@ -2050,12 +1537,11 @@ nav.top-nav { .f-table > .thead > .tr > .th, .f-table .tbody .tr .td { - background-color: white; + background-color: $pg-canvas; text-align: left; padding: 10px; text-overflow: ellipsis; white-space: nowrap; - // overflow: hidden; height: 100%; } @@ -2068,23 +1554,6 @@ nav.top-nav { .btcd-entries-f .tbody .tr .td:last-child { overflow: visible; } - -.btc-line-icn { - & .line-icn { - fill: none; - stroke: $dp-purple; - stroke-width: 16px; - } -} - -.border-icn { - width: 13px; - height: 13px; - border-width: 1.5px; - border-color: $dp-bg; - border-style: solid; -} - .btcd-hid-icn { display: none !important; } @@ -2115,89 +1584,6 @@ nav.top-nav { .btcd-t-action { margin-right: 20px; } - -.btcd-accr { - background: white; - border-radius: 8px; - - & > .btcd-accr-btn { - border-radius: 8px 8px 0 0; - outline: none; - padding: 8px; - cursor: pointer; - - & > .icn-btn { - font-weight: bold; - - &:hover { - background: rgba(0, 0, 0, 0.178); - } - } - - &:hover { - background-color: #eaedff; - - & .edit-icn { - display: inline-block; - } - } - - & > .btcd-accr-title > div { - & > input { - background: transparent; - border: none; - line-height: 1; - box-shadow: none; - min-height: 10px; - padding: 0; - margin: 0; - font-size: 15px; - border-radius: 5px; - outline: none; - cursor: pointer; - } - - & > div { - display: none; - margin-left: 3px; - - & > .btcd-icn { - color: transparentize(#000, 0.5); - font-size: 14px !important; - } - } - } - - & > .btcd-accr-title div > input.edit { - cursor: text; - - &:hover { - box-shadow: 0 0 0 1px transparentize(#000000, 0.8); - } - - &:focus { - background: transparentize($color: #fff, $amount: 0.4); - color: black !important; - box-shadow: 0 0 0 1px transparentize($dp-purple, 0.8); - padding: 0 4px; - } - } - } - - & .edit-icn { - display: none; - } -} - -.btcd-accr-title { - padding: 5px; - - & > small { - color: gray; - font-size: 12px; - } -} - .f-search { display: flex; font-size: 12 !important; @@ -2259,11 +1645,6 @@ nav.top-nav { height: 40px; font-size: 25px; } - -.btc-icn-md { - font-size: 20px; -} - .btcd-icn-sm { font-size: 17px; } @@ -2502,25 +1883,9 @@ nav.top-nav { .btcd-icn { pointer-events: none !important; } - -.btcd-prgrs-wrp { - margin-left: 7px; - width: 70%; - height: 6px; - border-radius: 10px; - overflow: hidden; - background-color: #c7d4ff; -} - -.btcd-prgrs { - height: inherit; - border-radius: inherit; - background-color: $purple; -} - -.btcd-menu { - position: relative; - font-weight: normal; +.btcd-menu { + position: relative; + font-weight: normal; } .btcd-menu-list { @@ -2688,17 +2053,6 @@ nav.top-nav { height: 100%; } } - -.btcd-m-md { - width: 500px !important; - height: auto !important; - min-height: 10% !important; - - & .content { - height: 100%; - } -} - .confirm-content { padding: 50px 10px; } @@ -3242,16 +2596,6 @@ $log-ease: cubic-bezier(0.23, 1, 0.32, 1); background: transparent; } } - -.submit-btn { - background: #1296ef; - // position: absolute; - margin: 25px; - position: absolute; - bottom: 0; - right: 0; -} - .btcd-mdl-close { background: #fafafa; position: absolute; @@ -3265,105 +2609,9 @@ $log-ease: cubic-bezier(0.23, 1, 0.32, 1); border: 0.5px solid #b0cae4; margin-top: 7px; } - -.btcd-mdl-hdr-btn { - position: absolute; - top: 2px; - right: 50px; -} - -.btcd-tem { - position: relative; - width: 150px; - height: 150px; - border: 2px solid #e0e0e0; - border-radius: 10px; - margin-top: 10px; - margin-left: 10px; - justify-content: center; - flex-direction: column; - overflow: hidden; - transition: border 300ms; - - & > div { - margin-top: 10px; - } - - &:hover { - border: 2px solid $purple; - box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.29); - } - - &:hover .btcd-hid-btn { - bottom: 0; - } -} - .sh-sm { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.32); } - -.sh-1 { - box-shadow: 0 0px 13px 0 rgba(207, 207, 207, 0.32); -} - -.btcd-hid-btn { - display: block; - background: $purple; - position: absolute; - bottom: -53px; - left: 0; - width: 100%; - text-align: center; - transition: bottom 300ms; - - & > a { - display: inline-block; - text-decoration: none; - } -} - -.btn-white { - background: white; - - &:hover { - background: #f1f1f1; - color: $purple; - } - - &:active { - background: #e4e4e4; - color: $purple; - } -} - -.btcd-builder-wrp { - background: - linear-gradient(90deg, #ffffff 12px, transparent 1%) center, - linear-gradient(#ffffff 12px, transparent 1%) center, - #000000; - // background: white !important; - position: fixed !important; - top: 80px !important; - right: 20px !important; - left: 20px !important; - height: 100%; - margin-bottom: 30px !important; - border-radius: 20px !important; - overflow: hidden !important; - box-shadow: 0px 0px 7px 0px rgba(132, 178, 176, 0.45); - background-size: 13px 13px; - // transition: left 0.5s, right 0.5s, top 0.5s, margin-bottom 0.5s, border-radius 0.5s 0.5s !important; -} - -.btcd-ful-scn { - top: 0 !important; - right: 0 !important; - left: 0 !important; - border-radius: 0 !important; - margin-bottom: 0 !important; -} - .btcd-drawer { background: white; position: fixed; @@ -3383,147 +2631,9 @@ $log-ease: cubic-bezier(0.23, 1, 0.32, 1); font-size: 17px; font-weight: bold; } - -.btcd-row-detail-tbl { - & tr th { - text-align: left; - padding: 10px; - min-width: 170px; - } - - & tr td { - padding: 10px; - word-break: break-all; - } -} - .br-50 { border-radius: 500px; } - -.btcd-bld-nav { - display: flex; - background: $dp-bg; - height: 40px; - overflow: hidden; - justify-content: space-between; -} - -.btcd-bld-lnk { - display: flex; - box-shadow: none; - - & > a { - display: inline-block; - background: transparentize(#fff, 0.9); - color: white; - padding: 6px 15px; - margin: 6px; - border-radius: 100px; - text-decoration: none; - line-height: 16px; - transition: background 0.3s !important; - - &:hover { - background: transparentize(#fff, 0.7); - } - } - - & > a > .btcd-icn { - font-size: 26px; - margin-right: 5px; - } - - & > a:nth-child(1) { - display: flex; - align-items: center; - width: 38px; - overflow: hidden; - padding: 8px; - transition: width 0.5s !important; - - &:hover { - width: 100px; - } - } -} - -.btcd-bld-btn { - display: flex; - align-items: center; - - & > button, - a { - height: 28px; - border-radius: 20px; - text-decoration: none; - margin: 5px 10px 5px 5px; - padding: 7px 25px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.76); - - &:disabled { - background: transparentize($purple, 0.5) !important; - } - } - - & > .btcd-btn-close { - background: transparentize(#fff, 0.6); - color: white; - font-size: 22px; - height: 30px; - width: 30px; - margin-right: 20px; - padding: 0; - border-radius: 100px; - text-align: center; - - &:hover { - background: rgb(255, 57, 57) !important; - } - } -} - -.pro-modal { - & h4 { - line-height: 1.5; - } - - & .btn { - background: crimson !important; - padding: 15px 50px; - border-radius: 100px; - } -} - -.btcd-bld-title { - margin-right: 200px; - - & > .btcd-bld-title-inp { - background: transparentize(#fff, 0.8); - color: white; - margin-top: 5px; - padding: 6px; - font-size: 16px; - border: none; - text-align: center; - outline: none; - } - - & > .btcd-bld-title-inp:hover { - background: transparentize(#fff, 0.6); - } - - & > .btcd-bld-title-inp:focus { - background: white; - color: #0e112f; - font-weight: bold; - } -} - -.tool-sec { - transition: flex-grow 500ms; -} - .app-link-active { background: $light-bg !important; font-weight: bold !important; @@ -3547,120 +2657,6 @@ $log-ease: cubic-bezier(0.23, 1, 0.32, 1); opacity: 1; } } - -.btcd-f-settings { - background-color: $light-bg; -} - -.btcd-f-sidebar { - background: $dp-bg; - position: fixed; - height: 100%; - z-index: 9; - width: 230px; - box-shadow: -3px -6px 7px -1px rgb(0, 0, 0) inset; - - &::before { - position: absolute; - right: -15px; - content: ''; - height: 15px; - width: 15px; - background-color: transparent; - border-radius: 15px 0 0 0; - box-shadow: -5px -3px 0 2px $dp-bg; - } - - & > a { - display: flex; - color: white; - position: relative; - align-items: center; - font-size: 15px; - padding: 12px 10px 12px 20px; - margin-left: 20px; - border-radius: 50px 0 0 50px; - text-decoration: none; - font-weight: 600; - margin-bottom: 25px; - - &:focus { - box-shadow: none; - } - - & > span { - margin-right: 10px; - } - } - - & > a:hover:not(.btcd-f-a) { - background: transparentize(#000000, 0.7); - } -} - -.btcd-f-a { - background: $light-bg; - color: $dp-purple !important; - box-shadow: -4px 2px 3px 0 black; - - &:before { - background: transparent; - position: absolute; - top: -18px; - right: 0; - content: ''; - width: 15px; - height: 18px; - border-radius: 0 0 15px 0; - box-shadow: 5px 4px 0 3px $light-bg; - } - - &:after { - background: #6b424200; - position: absolute; - right: 0; - bottom: -18px; - content: ''; - width: 15px; - height: 18px; - border-radius: 0 15px 0 0; - box-shadow: 5px -9px 0 4px $light-bg; - } -} - -.btcd-f-c-t-o { - display: inline-block; - background: white; - cursor: pointer; - border: 2.5px solid white; - outline: none; - text-align: center; - padding: 10px; - border-radius: 8px; - margin-top: 10px; - - &:hover:not(.btcd-f-c-t-o-a) { - border-color: transparentize($purple, 0.6); - } -} - -.btcd-f-c-t-o-a { - color: $purple; - position: relative; - border-color: $purple; - - &:after { - position: absolute; - top: 100%; - left: 50%; - content: ''; - margin-left: -10px; - border-width: 8px; - border-style: solid; - border-color: $purple transparent transparent transparent; - } -} - .btcd-s-wrp { position: relative; padding: 30px; @@ -3671,20 +2667,6 @@ $log-ease: cubic-bezier(0.23, 1, 0.32, 1); padding: 30px 10px !important; } } - -.btcd-s-inp { - font-size: 16px; - border-radius: 8px; - outline: none; - width: 60%; - line-height: 0; - border: 2px solid #c6e5ff; - - &:focus { - border: 2px solid $purple; - } -} - .f-lg { font-size: 20px; } @@ -3734,6 +2716,21 @@ $log-ease: cubic-bezier(0.23, 1, 0.32, 1); max-width: 800px; } +// The library paints a background only on the active and disabled control, so an +// idle multiselect let the page canvas show through, and its border is a dark +// #9c9c9c against the #e2e2e2 every other input uses. Integration wrapper classes +// are inconsistent, so match on the library's own markup instead — and note its +// stylesheet ships in a lazy chunk, i.e. loads after this file, so a bare `.msl` +// here would lose. `.msl-disabled > .msl` still wins for the disabled grey. +.msl-wrp > .msl { + background: #fff; + border-color: #e2e2e2; + + &:not(.msl-active, .msl-active-up):hover { + border-color: $dp-txt; + } +} + .btcd-paper-drpdwn { --menu-max-height: 300px; --font-size: 15px; @@ -3792,131 +2789,15 @@ select.btcd-paper-inp { &:hover:not(:disabled), :focus { color: $dp-purple !important; - // background-position-y: 12px; } } - -.btcd-date-pick > .react-date-picker__wrapper, -.react-time-picker__wrapper { - font-family: inherit; - line-height: 1 !important; - font-size: 15px !important; - padding: 9px !important; - border-radius: 8px; - border: 1px solid #e2e2e2 !important; - - &:focus { - border-color: $purple !important; - box-shadow: - 0 0 0 0px $purple, - 0 0 0 4px rgba(0, 131, 243, 0.1) !important; - } - - &:hover:not(:disabled, :focus) { - border-color: $dp-txt !important; - } - - & > button > svg { - stroke: rgb(53, 53, 53); - } - - & input { - border: none; - box-shadow: none; - line-height: 1; - background-color: none; - padding: 0; - min-height: 0; - } - - & select { - font-size: 14px; - padding: 0 22px 0 5px; - min-width: 0; - min-height: 0; - height: auto I !important; - border-radius: 5px; - border-color: #ececec; - line-height: 1; - } -} - -.react-time-picker__inputGroup__amPm { - border: 1px solid #ececec !important; - border-radius: 7px; -} - -.btcd-date-pick-cln, -.react-time-picker__clock { - border-radius: 10px; - border: none !important; - box-shadow: 0px 0px 4px #b8b8b8; -} - -.btcd-setting-opt { - height: 48px; - padding: 0 5px 0 15px; -} - -.btcd-em-n-t-wrp { - padding: 20px 0 20px 0; - box-shadow: 0px 3px 5px 1px #0000002b; - border-radius: 20px; - margin-top: 15px; - height: 400px; - overflow: auto; -} - -.btcd-em-n-t { - width: 100%; - border-collapse: collapse; - - & > thead > tr > th { - color: #637192; - text-align: left; - padding: 0 0 10px 10px; - border-bottom: 2px solid #dce1f3; - } - - & > tbody > tr { - &:hover { - background: #f4f6ff; - } - } - - & > tbody > tr > td { - color: $dp-purple; - padding: 10px 0 10px 10px; - border-bottom: 1px solid #c9d6e2; - - &:hover { - background: #eeeefd; - } - } -} - -.btcd-tabl-lnk { - color: $purple; - text-decoration: none; - - &:hover { - color: $dp-purple; - } - - &:focus { - color: $dp-purple !important; - outline: none !important; - box-shadow: none !important; - } -} - -//table checkbox start -.btcd-label-cbx { - display: inline-block; - -ms-user-select: none; - -webkit-user-select: none; - cursor: pointer; - margin-bottom: 0; +//table checkbox start +.btcd-label-cbx { + display: inline-block; + -ms-user-select: none; + -webkit-user-select: none; + cursor: pointer; + margin-bottom: 0; input { &:checked { @@ -3942,8 +2823,6 @@ select.btcd-paper-inp { &:indeterminate { + { .btcd-t-cbx { - // border-color: $purple; - // background: $purple; border-color: #808080cf; background: #808080cf; @@ -3974,7 +2853,6 @@ select.btcd-paper-inp { margin-right: 8px; width: 18px; height: 18px; - // border: 1.5px solid #7902f899; border: 1px solid #808080cf; border-radius: 50%; background: transparent; @@ -4037,7 +2915,6 @@ select.btcd-paper-inp { right: 0; width: 15px; height: 100%; - //border-left: 1px solid #eaeaea; transform: translateX(50%); z-index: 1; touch-action: none; @@ -4053,25 +2930,92 @@ select.btcd-paper-inp { position: relative; } +// Shared open/close motion for the popover menus, ported from the row-action +// menu (.btcd-menu-list / .btcd-m-a): the panel unrolls like a book from the +// edge its trigger sits on. +// +// The original unrolls by animating `width`, which relays out the panel and +// everything inside it on every frame. Here the panel keeps its full size and +// a `clip-path` inset walks across it instead — same read, but it is a paint, +// so the content is laid out once and simply uncovered. The insets run +// negative so the drop shadow lives outside the clip and survives the reveal. +// +// Each menu sets --menu-width (what it opens to) and --menu-radius (its +// resting corner); the closed and open states read both. +$menu-open-time: 280ms; +$menu-close-time: 200ms; +$menu-ease: cubic-bezier(0.23, 1, 0.32, 1); +$menu-bleed: 40px; + +// Height never joins in: it cannot interpolate to `auto`, so collapsing it +// would snap the content away and swallow the reveal on close. The original +// hits the same wall and writes a measured pixel height inline, leaving its +// CSS `height: 0` dead. +@mixin menu-book-closed($width, $hinge: right, $radius: 10px) { + --menu-width: #{$width}; + --menu-radius: #{$radius}; + @if $hinge == right { + --menu-clip-shut: inset(#{-$menu-bleed} #{-$menu-bleed} #{-$menu-bleed} 100%); + } @else { + --menu-clip-shut: inset(#{-$menu-bleed} 100% #{-$menu-bleed} #{-$menu-bleed}); + } + + visibility: hidden; + width: var(--menu-width); + border-radius: 999px; + -webkit-clip-path: var(--menu-clip-shut); + clip-path: var(--menu-clip-shut); + pointer-events: none; + transition: + clip-path $menu-close-time $menu-ease, + -webkit-clip-path $menu-close-time $menu-ease, + border-radius $menu-close-time $menu-ease, + opacity $menu-close-time $menu-ease, + visibility 0s linear $menu-close-time; + opacity: 0; +} + +@mixin menu-book-open { + visibility: visible; + opacity: 1; + border-radius: var(--menu-radius); + -webkit-clip-path: inset(-$menu-bleed); + clip-path: inset(-$menu-bleed); + pointer-events: auto; + transition: + clip-path $menu-open-time $menu-ease, + -webkit-clip-path $menu-open-time $menu-ease, + border-radius $menu-open-time $menu-ease, + opacity 140ms ease-out, + visibility 0s; +} + +// The trigger answers the press before the panel finishes opening, the same +// way the row-action button does. +@mixin menu-trigger-press { + transition: transform 140ms ease-out; + + &:active { + transform: scale(0.94); + } +} + .btcd-menu-li { - display: none; + @include menu-book-closed(200px); + + display: block; background: transparentize($color: #fff, $amount: 0.1); position: absolute; - height: 350px; - width: max-content; -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px); box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.22); z-index: 999; - border-radius: 10px; overflow: hidden; & > div:not(.btcd-m-li-itm) { - //height: 280px !important; overflow-y: auto; overflow-x: hidden; width: 200px; - // mpadding: 10px; min-width: 100px; border-bottom: 1px solid #eaeaf1; /* & :hover { @@ -4079,15 +3023,6 @@ select.btcd-paper-inp { } */ } } - -.btcd-col-vis { - background: red; - right: 40px; - width: 200px; - height: 100px; - z-index: 9; -} - .btcd-pane { white-space: nowrap; width: 100% !important; @@ -4143,7 +3078,7 @@ select.btcd-paper-inp { } .btcd-menu-a { - display: block; + @include menu-book-open; } .btcd-t-actions { @@ -4188,43 +3123,11 @@ select.btcd-paper-inp { } } } - -.btcd-eye-t { - display: inline-block; - -ms-user-select: none; - -webkit-user-select: none; - cursor: pointer; - - input:checked { - + { - span > .eye-t-v { - display: inline-block; - } - - span > .eye-t-h { - display: none; - } - } - } - - .eye-t-v { - display: none; - } -} - #form-res { background: white; height: 100%; overflow: auto; } - -.btcd-pill { - background: transparentize($purple, 0.8); - color: $dp-purple; - padding: 5px; - border-radius: 5px; -} - .scl-7 { transform: scale(0.7); } @@ -4397,8 +3300,6 @@ select.btcd-paper-inp { } .bit-logo { - // background: rgb(15, 16, 49); - // background: linear-gradient(43deg, rgba(15, 16, 49, 1) 0%, rgba(29, 58, 75, 1) 100%); padding: 15px 15px 15px 19px; border-radius: 28px; box-shadow: 0 0 22px -7px #9498ce; @@ -4418,48 +3319,6 @@ select.btcd-paper-inp { width: 85%; } } - -.btc-s-l { - display: block; - background: white; - color: $dp-txt; - cursor: pointer; - width: 90%; - border-radius: 8px; - padding: 7px 12px; - margin-left: auto; - margin-right: 20px; - font-weight: 500; - //box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); - - & .lft-icn { - background: #f5f5f5; - color: $dp-purple; - min-width: 40px; - min-height: 40px; - border-radius: 30; - justify-content: center; - } - - &:hover { - color: $purple; - - & .lft-icn { - background: $light-purple-bg; - color: $purple; - - & .line-icn { - stroke: $purple; - } - - & .fill-icn { - fill: $purple; - } - } - } -} - .btcd-li-side-btn { display: none; position: absolute; @@ -4470,18 +3329,6 @@ select.btcd-paper-inp { .pos-rel { position: relative; } - -.pos-abs { - position: absolute; -} - -.btcd-flow-icn { - color: transparentize($dp-txt, 0.7); - font-size: 24px; - font: bolder; - line-height: 0; -} - .rev-icn { transform: rotateY(180deg); -webkit-transform: rotateY(180deg); @@ -4489,15 +3336,6 @@ select.btcd-paper-inp { -o-transform: rotateY(180deg); -ms-transform: rotateY(180deg); } - -.btcd-wrk-logic { - &:hover { - & .btcd-li-side-btn { - display: inline-block; - } - } -} - .btcd-mt-inp { display: inline-block; color: $dp-bg !important; @@ -4768,53 +3606,6 @@ select.btcd-paper-inp { } } } - -.btcd-ud-inp { - width: 100%; - font-family: inherit; - line-height: 1 !important; - font-size: 15px !important; - padding: 10px !important; - outline: none; - border-radius: 8px !important; - min-height: 15px !important; - border: 1px solid #e2e2e2 !important; - - &:focus { - border: 0; - box-shadow: 0 0 0 3px $purple inset !important; - } -} - -select.btcd-ud-inp { - background: url($select-arrow) no-repeat right #fff; - appearance: none; - transition: - background-position-y 0.2s, - border 0.2s, - box-shadow 0.2s; -} - -.btcd-lgc { - display: inline-block; - color: #00a6ff; - border: 2px solid #00a6ff; - padding: 4px 10px; - border-radius: 10px; - font-weight: bold; - font-size: 15px; -} - -.btcd-lgc-sm { - display: inline-block; - color: #6faaff; - border: 1.5px solid #6faaff; - padding: 2px 7px; - border-radius: 8px; - font-size: 11px; - font-weight: bold; -} - .btcd-logic-grp { border: 1px solid #b9c5ff; @@ -4841,41 +3632,6 @@ select.btcd-ud-inp { box-shadow: none; } } - -.btcd-lgc-btns { - display: inline-block; - - & button { - transform: scale(0); - transition: transform 0.3s; - - &:nth-child(1) { - transform: scale(1); - } - } - - &:hover { - & button:nth-child(2) { - transform: scale(1); - } - - & button:nth-child(3) { - transition-delay: 0.1s; - transform: scale(1); - } - - & button:nth-child(4) { - transition-delay: 0.2s; - transform: scale(1); - } - - & button:nth-child(5) { - transition-delay: 0.3s; - transform: scale(1); - } - } -} - .btcd-logic-blk, .workflow-grp { &:hover { @@ -4885,147 +3641,29 @@ select.btcd-ud-inp { } } } +.btcd-neu-btn.icn { + border-radius: 30px; + width: 30px; + height: 30px; + padding: 6px 9px; +} +.btcd-stp { + background: rgb(236, 236, 236); + color: gray; + height: 35px; + width: 35px; + font-weight: bold; + -ms-user-select: none; + -webkit-user-select: none; + border: 2px solid rgb(236, 236, 236); + border-radius: 30px; -.btcd-neu-table { - padding: 10px; - border-radius: 10px; - border: 1px solid #b2cce3; - - & .thead { - color: #244679; - font-weight: bold; - font-size: 14px; - padding: 5px 5px 15px 5px; - border-bottom: 2px solid #b2cce3; - - & .th { - padding-left: 5px; - border-right: 1px solid rgb(191, 199, 221); - } - - & .th:last-child { - border: none; - } - } - - & .tbody { - margin: 3px; - - & .tr { - outline: none; - padding: 5px; - border-bottom: 1px solid #b2cce3; - - &:last-child { - border: none; - } - } - - & .td { - border-right: 1px solid #b2cce3; - padding-left: 5px; - - & a:hover { - color: $dp-purple; - } - - &:hover:not(:last-child) { - background: #ecf1ff; - border-radius: 5px; - } - - &:last-child { - border: none; - } - } - } -} - -.btcd-neu-btn { - display: inline-block; - background: $light-bg; - color: $dp-bg; - text-decoration: none; - cursor: pointer; - box-shadow: - 6px 6px 10px rgba(120, 136, 194, 0.4), - -6px -6px 10px rgba(255, 255, 255, 0.9); - border-radius: 10px; - padding: 10px; - border: none; - outline: none; - transition: - transform 0.2s, - box-shadow 0.2s; - - &:hover { - background-color: darken($light-bg, 5); - } - - &:active { - box-shadow: - 3px 3px 6px rgba(120, 136, 194, 0.4), - -6px -6px 10px rgba(255, 255, 255, 0.9); - transform: scale(0.9); - } -} - -.btcd-neu-btn.icn { - border-radius: 30px; - width: 30px; - height: 30px; - padding: 6px 9px; -} - -.btcd-neu-btn.neu-sh-sm { - box-shadow: - 3px 3px 6px rgba(120, 136, 194, 0.4), - -3px -3px 6px rgba(255, 255, 255, 0.9); -} - -.btcd-neu-inp { - display: inline-block; - background-color: $light-bg; - border-radius: 10px; - - & input, - textarea { - background: lighten($light-bg, 5); - width: 100%; - border: 3px solid $light-bg; - outline: none; - margin: 0; - transition: - box-shadow 0.2s, - border 0.2s; - - &:focus { - background: white; - box-shadow: - 1px 1px 2px rgba(120, 136, 194, 0.4) inset, - -1px -1px 2px rgba(255, 255, 255, 0.9) inset; - border: 3px solid darken($light-bg, 7); - } - } -} - -.btcd-stp { - background: rgb(236, 236, 236); - color: gray; - height: 35px; - width: 35px; - font-weight: bold; - -ms-user-select: none; - -webkit-user-select: none; - border: 2px solid rgb(236, 236, 236); - border-radius: 30px; - - &.stp-a { - background: transparentize($purple, 0.9); - color: $purple; - border-color: $purple; - } -} + &.stp-a { + background: transparentize($purple, 0.9); + color: $purple; + border-color: $purple; + } +} .btcd-stp-line { display: inline-block; @@ -5107,119 +3745,6 @@ select.btcd-ud-inp { margin-bottom: 80px; } } - -.btcd-device-btn { - position: absolute; - top: 7px; - right: 20%; - - & .active { - background: transparentize(#fff, 0.7); - color: transparentize(#fff, 0); - } - - & > button { - background: transparentize(#fff, 0.9); - color: transparentize(#fff, 0.4); - cursor: pointer; - outline: none; - width: 34px; - height: 29px; - margin-right: 5px; - border-radius: 5px; - border: none; - font-size: 21px; - - &:hover { - background: transparentize(#fff, 0.7); - color: transparentize(#fff, 0); - } - - &:focus { - color: transparentize(#fff, 0); - box-shadow: 0 0 0 2px $purple; - } - } - - & button:nth-child(2) > span { - transform: rotate(90deg); - } - - & button.lap { - padding-top: 4px; - font-size: 23px; - } -} - -.resp-btn { - /* & .active{ - background-color: ; - } */ - & button:nth-child(2) { - & .btcd-icn { - transform: rotate(90deg); - } - } - - & button { - background: transparent; - color: $purple; - outline: none; - border: 1px solid $purple; - width: 22px; - height: 22px; - padding: 3px; - cursor: pointer; - justify-content: center; - - &:hover { - background: transparentize($purple, 0.9); - } - } -} - -.btcd-app-setting-sidebar { - display: inline-block; - padding: 15px; - min-height: 82vh; - border-right: 1px solid #e8e8e8; - - & > a { - display: flex; - color: $dp-bg; - padding: 12px 15px; - align-items: center; - width: 200px; - margin-bottom: 10px; - border-radius: 50px; - font-weight: 600; - - & > span { - margin-right: 10px; - } - - &:hover { - background: transparentize($dp-bg, 0.9); - } - } -} - -.btcd-app-s-a { - background: $dp-bg; - color: white !important; - font-weight: bold; - - &:hover { - background: transparentize($dp-bg, 0.08) !important; - } -} - -.btcd-captcha { - & h2 { - margin: 15px 0 5px 0; - } -} - .btcd-link { color: blue; box-shadow: none !important; @@ -5250,50 +3775,6 @@ _:-ms-fullscreen, border-bottom: 1px solid; border-color: #eeeeee; } - -.coming-feature { - display: flex; - background: #07316f38; - pointer-events: none; - justify-content: center; - align-items: center; - padding: 5px; - -webkit-backdrop-filter: blur(1px); - backdrop-filter: blur(1px); - border-radius: 8px; - - &::before { - background: white; - position: absolute; - top: 60%; - content: 'Coming Soon'; - padding: 5px 7px; - font-size: 15px; - border-radius: 10px; - background-color: white; - box-shadow: 0px 0 29px -9px #1e1d3f; - } -} - -.btcd-ttc { - text-transform: capitalize; -} - -// paypal -.btcd-code { - color: #212121; - padding: 3px; - border-radius: 5px; -} - -.btcd-ttl-ellipsis { - display: inline-block; - width: 76%; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} - .btcd-hr { max-width: 97%; border-bottom: 1px solid #e2e2e2; @@ -5373,11 +3854,6 @@ _:-ms-fullscreen, .flx-end { justify-content: end; } - -.w-nwrp { - white-space: nowrap; -} - .m-a { margin: auto; } @@ -5398,22 +3874,6 @@ _:-ms-fullscreen, margin-left: 0 !important; margin-right: 0 !important; } - -.mx-1 { - margin-left: 5px; - margin-right: 5px; -} - -.mx-2 { - margin-left: 10px; - margin-right: 10px; -} - -.mx-3 { - margin-left: 15px; - margin-right: 15px; -} - .my-1 { margin-top: 5px; margin-bottom: 5px; @@ -5452,11 +3912,6 @@ _:-ms-fullscreen, .mb-25 { margin-bottom: 125px; } - -.mb-50 { - margin-bottom: 250px; -} - .mt-0 { margin-top: 0 !important; } @@ -5524,11 +3979,6 @@ _:-ms-fullscreen, .ml-2 { margin-left: 10px; } - -.ml-4 { - margin-left: 20px !important; -} - .ml-5 { margin-left: 25px !important; } @@ -5549,28 +3999,9 @@ _:-ms-fullscreen, padding: auto; } -.p-1 { - padding: 5px; -} - -.p-2 { - padding: 10px; -} - .p-0 { padding: 0 !important; } - -.px-0 { - padding-left: 0 !important; - padding-right: 0 !important; -} - -.py-1 { - padding-top: 5px; - padding-bottom: 5px; -} - .py-2 { padding-top: 10px; padding-bottom: 10px; @@ -5580,47 +4011,13 @@ _:-ms-fullscreen, padding-top: 15px; padding-bottom: 15px; } - -.pb-0 { - padding-bottom: 0; -} - -.pb-1 { - padding-bottom: 5px; -} - -.pb-2 { - padding-bottom: 10px; -} - .pb-3 { padding-bottom: 15px; } - -.pb-4 { - padding-bottom: 20px; -} - -.pb-25 { - padding-bottom: 125px; -} - -.pb-50 { - padding-bottom: 250px; -} - .pt-0 { padding-top: 0 !important; } -.pt-1 { - padding-top: 5px; -} - -.pt-2 { - padding-top: 10px; -} - .pt-3 { padding-top: 15px; } @@ -5636,79 +4033,15 @@ _:-ms-fullscreen, .pt-i-3 { padding-top: 15px !important; } - -.pr-1 { - padding-right: 5px; -} - -.pr-2 { - padding-right: 10px; -} - -.pr-4 { - padding-right: 20px; -} - -.pr-8 { - padding-right: 40px; -} - -.pr-16 { - padding-right: 80px; -} - -.pr-18 { - padding-right: 93px; -} - -.pr-24 { - padding-right: 118px; -} - -.pl-0 { - padding-left: 0 !important; -} - -.pl-1 { - padding-left: 5px; -} - .pl-2 { padding-left: 10px; } - -.pl-4 { - padding-left: 20px !important; -} - -.pl-5 { - padding-left: 25px !important; -} - .pl-6 { padding-left: 30px !important; } - -.pl-7 { - padding-left: 35px !important; -} - -.pl-30 { - padding-left: 150px !important; -} - -.w-md { - width: 900px; -} - .w-1 { width: 10% !important; } - -.w-2 { - width: 20% !important; -} - .w-3 { width: 30% !important; } @@ -5800,12 +4133,6 @@ _:-ms-fullscreen, .pl-6 { padding-left: 30px; } - -.py-1 { - padding-top: 5px; - padding-bottom: 5px; -} - .py-2 { padding-top: 10px; padding-bottom: 10px; @@ -5814,33 +4141,9 @@ _:-ms-fullscreen, .br-10 { border-radius: 10px !important; } - -.br-15 { - border-radius: 15px; -} - -.col-2 { - columns: 2; -} - .w-a { width: auto !important; } - -.txt-o { - text-overflow: ellipsis; -} - -.us-n { - -webkit--ms-user-select: none; - -webkit-user-select: none; - -khtml--ms-user-select: none; - -moz--ms-user-select: none; - -o--ms-user-select: none; - -ms-user-select: none; - user-select: none; -} - .svg-icn { fill: none; stroke: currentColor; @@ -5871,15 +4174,6 @@ _:-ms-fullscreen, font-weight: 100; font-family: 'Titillium Web', sans-serif; } - -.cooltip-link { - color: #2ad4ff; -} - -.cooltip-link:hover { - color: #00bdec; -} - .r-n-1 { right: -4px; } @@ -5921,8 +4215,6 @@ _:-ms-fullscreen, .webhook-table-scroll td, .webhook-table-scroll th { - //flex-basis: 100%; - //flex-grow : 2; display: block; padding: 1rem; text-align: left; @@ -5976,23 +4268,12 @@ _:-ms-fullscreen, .txt-right-imp { text-align: right !important; } - -.wc-btn { - border: 1px solid #b1a1a1; - width: 80px; - height: 24px; - border-radius: 5px; - text-align: center; - padding-top: 3px; -} - .tab-box { display: flex; width: 97%; border-bottom: 1px solid #0069ff; .tab-item { - // flex: 1; text-align: center; padding: 10px; cursor: pointer; @@ -6031,181 +4312,19 @@ _:-ms-fullscreen, justify-content: center; } -.dropdown-custom-width { - --input-width: 50% !important; -} - -.tagify--mix { - width: 330px; - border-radius: 7px; -} - -// Input Group -.btcd-input-group { - display: table; - border-collapse: collapse; - - div { - display: table-cell; - border: 1px solid #ddd; - vertical-align: middle; - /* needed for Safari */ - } - - .btcd-input-group-icon { - background: rgb(230, 230, 230); - color: #031b4e; - padding: 0 12px; - border: none !important; - } - - .btcd-input-group-icon:first-child { - border-radius: 8px 0 0 8px !important; - -webkit-border-radius: 8px 0 0 8px !important; - -moz-border-radius: 8px 0 0 8px !important; - -ms-border-radius: 8px 0 0 8px !important; - -o-border-radius: 8px 0 0 8px !important; - } - - .btcd-input-group-icon:last-child { - border-radius: 0 8px 8px 0 !important; - -webkit-border-radius: 0 8px 8px 0 !important; - -moz-border-radius: 0 8px 8px 0 !important; - -ms-border-radius: 0 8px 8px 0 !important; - -o-border-radius: 0 8px 8px 0 !important; - } - - .btcd-input-group-area { - width: 100%; - border: none !important; - } - - input { - border-radius: unset !important; - -webkit-border-radius: unset !important; - -moz-border-radius: unset !important; - -ms-border-radius: unset !important; - -o-border-radius: unset !important; - } -} - -.btcbi-field-map-button { - .icn-btn { - margin-right: 9%; - } -} - -.user-radio-input { - .auth-list { - display: flex; - flex-direction: column; - gap: 10px; - } - - .auth-item { - display: flex; - align-items: center; - padding: 10px; - border: 1px solid #ccc; - border-radius: 5px; - background: #f9f9f9; - position: relative; - } - - .auth-item.active { - border-color: purple; - background: #e0dff9; - } - - .auth-label { - display: flex; - align-items: center; - cursor: pointer; - width: 100%; - } - - .radio-input { - position: absolute; - opacity: 0; - cursor: pointer; - } - - .auth-info { - display: flex; - align-items: center; - gap: 10px; - } - - .user-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - } - - .user-name { - font-weight: bold; - } - - .user-email { - font-size: 14px; - color: #666; - } - - .delete-button { - background: none; - border: none; - color: red; - font-size: 20px; - cursor: pointer; - margin-left: auto; - } - - .confirmation-popover { - position: absolute; - top: 50%; - left: calc(80% + 10px); - /* Show it to the right of the delete button */ - transform: translateY(-50%); - background-color: white; - border: 1px solid #ccc; - border-radius: 8px; - padding: 20px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - z-index: 10; - } - - .confirmation-popover p { - margin: 0; - font-size: 1em; - color: #333; - } - - .confirm-button, - .cancel-button { - background-color: #f10000; - border: none; - border-radius: 4px; - padding: 5px 10px; - color: white; - cursor: pointer; - margin-right: 5px; - font-size: 0.9em; - transition: background-color 0.3s; - } - - .confirm-button:hover { - background-color: #ce0000; - } - - .cancel-button { - background-color: #6c757d; - } +.dropdown-custom-width { + --input-width: 50% !important; +} - .cancel-button:hover { - background-color: #5a6268; +.tagify--mix { + width: 330px; + border-radius: 7px; +} +.btcbi-field-map-button { + .icn-btn { + margin-right: 9%; } } - .changelog-toggle { .changelog-btn { border-radius: 12px; @@ -6214,17 +4333,6 @@ _:-ms-fullscreen, } .changelog { - // max-height: calc(100vh - 210px); - // overflow-y: auto; - - .changelog-notif { - background-color: #ff000012; - color: #d00c0cd1; - padding: 4px 8px; - text-align: center; - border-radius: 5px; - } - .whats-new { margin: 0px 0px 35px 0px; @@ -6237,7 +4345,6 @@ _:-ms-fullscreen, max-height: 30vh; overflow-y: auto; padding-top: 5px; - // min-height: 220px; & ul { margin-left: 35px; @@ -6363,7 +4470,6 @@ _:-ms-fullscreen, border: 1px solid #008228; border-radius: 8px; font-size: 14px; - // text-decoration: underline; margin-top: 16px; } @@ -6395,99 +4501,6 @@ _:-ms-fullscreen, } } -.tutoriallink-btn { - align-items: center; - background: transparent; - color: inherit; - cursor: pointer; - display: inline-flex; - font: inherit; - text-decoration: none; -} - -.tutoriallink-btn:focus-visible { - outline: 2px solid #2563eb; - outline-offset: 2px; -} - -.tutoriallink-ai-picker { - display: inline-block; - margin-top: 16px; - position: relative; - - .tutoriallink { - margin-top: 0; - } -} - -.ai-tool-dropdown { - background: #fff; - border: 1px solid #d9dce3; - border-radius: 10px; - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.12); - padding: 10px; - position: absolute; - left: 0; - top: calc(100% + 8px); - width: min(92vw, 360px); - z-index: 20; -} - -.ai-tool-dropdown-title { - color: #1f2937; - font-size: 13px; - font-weight: 600; - margin: 0 0 8px; -} - -.ai-tool-grid { - display: grid; - gap: 8px; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.ai-tool-option { - align-items: center; - background: #f8fafc; - border: 1px solid #e5e7eb; - border-radius: 8px; - color: #111827; - display: inline-flex; - gap: 8px; - min-height: 36px; - padding: 7px 8px; - text-decoration: none; - transition: - background-color 0.2s ease, - border-color 0.2s ease; -} - -.ai-tool-option:hover { - background: #f3f4f6; - border-color: #cbd5e1; - text-decoration: none !important; -} - -.ai-tool-icon { - align-items: center; - display: inline-flex; - flex-shrink: 0; - height: 24px; - justify-content: center; - width: 24px; -} - -.ai-tool-logo { - display: block; - height: 24px; - width: 24px; -} - -.ai-tool-name { - font-size: 13px; - font-weight: 600; -} - @media only screen and (max-width: 767px) { .tutoriallink-ai-picker { display: block; @@ -6504,6 +4517,8 @@ _:-ms-fullscreen, } .tutoriallink-btn { + @include menu-trigger-press; + align-items: center; background: transparent; color: inherit; @@ -6529,18 +4544,22 @@ _:-ms-fullscreen, } .ai-tool-dropdown { + @include menu-book-closed(min(92vw, 360px), left); + background: #fff; border: 1px solid #d9dce3; - border-radius: 10px; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.12); padding: 10px; position: absolute; left: 0; top: calc(100% + 8px); - width: min(92vw, 360px); z-index: 20; } +.ai-tool-dropdown-open { + @include menu-book-open; +} + .ai-tool-dropdown-title { color: #1f2937; font-size: 13px; @@ -6644,7 +4663,6 @@ _:-ms-fullscreen, .field { background-color: #f0f0f0; - //background-color: lightgray; border-radius: 12px 0px 12px; padding: 8px 12px; border: none; @@ -6669,21 +4687,6 @@ _:-ms-fullscreen, } } } - -/* Tag Management Styles */ -.tag-filter-container { - padding: 16px; - background: linear-gradient(135deg, #f6f9fc 0%, #ffffff 100%); - border-radius: 12px; - margin-bottom: 16px; - border: 2px solid #e2e8f0; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); -} - -.tag-filter-section { - margin-bottom: 12px; -} - .tag-filter-inline { display: flex; align-items: center; @@ -6874,23 +4877,6 @@ _:-ms-fullscreen, background-color: #f6f8fb; } } - -.tag-filter-count { - margin: 6px 0 0 0; - font-size: 12px; - color: #64748b; - font-weight: 600; - background-color: #f1f5f9; - padding: 6px 12px; - border-radius: 8px; - display: inline-block; - - .highlight { - color: #6f42c1; - font-weight: 700; - } -} - @media only screen and (max-width: 767px) { .tag-filter-title { font-size: 14px; @@ -7150,7 +5136,6 @@ _:-ms-fullscreen, } .msl-options { - // border: 1px solid #d7dce7; border-radius: 12px; box-shadow: 0 14px 26px rgba(21, 29, 45, 0.14); margin-top: 7px; @@ -7303,100 +5288,13 @@ _:-ms-fullscreen, width: min(94vw, 410px); } } - -/* Tag Assignment Modal */ -.tag-assign-modal-content { - background-color: white; - padding: 28px; - border-radius: 14px; - min-width: 400px; - max-width: 480px; - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3); - animation: slideIn 0.3s ease-out; -} - -.tag-assign-item { - display: flex; - align-items: center; - padding: 10px 14px; - border: 2px solid #e2e8f0; - border-radius: 10px; - cursor: pointer; - background-color: white; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); - - &.assigned { - border-color: #6f42c1; - background-color: rgba(111, 66, 193, 0.1); - box-shadow: 0 3px 8px rgba(111, 66, 193, 0.3); - } - - &:hover:not(.assigned) { - border-color: rgba(111, 66, 193, 0.6); - background-color: rgba(111, 66, 193, 0.05); - } - - input[type='checkbox'] { - margin-right: 12px; - width: 16px; - height: 16px; - cursor: pointer; - } -} - -.tag-assign-badge { - padding: 4px 12px; - border-radius: 16px; - background-color: rgba(111, 66, 193, 0.2); - color: #6f42c1; - border: 2px solid #6f42c1; - font-size: 12px; - font-weight: 600; -} - -.tag-assign-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.tag-assign-empty { - color: #a0aec0; - text-align: center; - padding: 16px; - background-color: #f7fafc; - border-radius: 8px; - font-size: 13px; -} - -.tag-modal-btn-done { - padding: 8px 24px; - background-color: #6f42c1; - color: white; - border: none; - border-radius: 8px; - cursor: pointer; - font-weight: 600; - font-size: 13px; - transition: all 0.2s; - box-shadow: 0 3px 8px rgba(111, 66, 193, 0.3); - - &:hover { - background-color: #5a32a3; - transform: translateY(-1px); - } -} - /* Table Tag Badge */ .table-tag-badge { padding: 4px 8px 4px 10px; border-radius: 999px; font-size: 13px; - // background-color: #f3e8ff; background-color: #eef1f4; color: #2f3340; - // border: 1px solid #d6dbe3; white-space: nowrap; font-weight: 400; line-height: 1; @@ -7414,10 +5312,8 @@ _:-ms-fullscreen, .table-tag-badge-label { max-width: 84px; - // overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - // color: #5000a5; font-weight: 500; } @@ -7608,6 +5504,8 @@ _:-ms-fullscreen, } .btcd-bulk-menu-btn { + @include menu-trigger-press; + width: 34px; height: 34px; min-width: 34px; @@ -7620,19 +5518,23 @@ _:-ms-fullscreen, } .btcd-bulk-menu-list { + @include menu-book-closed(220px); + position: absolute; top: calc(100% + 6px); right: 0; left: auto; - min-width: 220px; padding: 6px; border: 1px solid #d8ddea; - border-radius: 10px; background: #fff; box-shadow: 0 8px 22px rgba(27, 33, 50, 0.12); z-index: 40; } +.btcd-bulk-menu-list-open { + @include menu-book-open; +} + .btcd-bulk-menu-list button { width: 100%; border: 0; @@ -7692,10 +5594,17 @@ _:-ms-fullscreen, display: inline-flex; align-items: center; gap: 8px; + // This button already owns a transition list, so the press has to join it — + // a second `transition` declaration would drop the colour fades. transition: color 0.2s cubic-bezier(0.23, 1, 0.32, 1), border-color 0.2s cubic-bezier(0.23, 1, 0.32, 1), - background 0.2s cubic-bezier(0.23, 1, 0.32, 1); + background 0.2s cubic-bezier(0.23, 1, 0.32, 1), + transform 140ms ease-out; + + &:active { + transform: scale(0.94); + } } .btcd-columns-btn .btcd-icn { @@ -7709,20 +5618,18 @@ _:-ms-fullscreen, } .btcd-table-top .btcd-columns-menu { + --menu-width: 214px; + top: calc(100% + 8px); right: 0; left: auto; - height: auto; - min-width: 214px; max-height: none; padding: 6px 4px 6px 6px; border: 1px solid #d8ddea; - border-radius: 10px; background: #fff; box-shadow: 0 8px 22px rgba(27, 33, 50, 0.12); -webkit-backdrop-filter: none; backdrop-filter: none; - overflow: visible; z-index: 1200; } @@ -7803,7 +5710,7 @@ _:-ms-fullscreen, .btcd-scroll { border: 1px solid #d9deea; border-radius: 8px; - background: #fff; + background: $pg-canvas; overflow: hidden; -webkit-border-radius: 8px; -moz-border-radius: 8px; @@ -7837,12 +5744,12 @@ _:-ms-fullscreen, .f-table .tbody .tr { height: 56px; - background: #fff; + background: $pg-canvas; box-shadow: none; } .f-table .tbody .tr:hover { - background: #fbfcff; + background: #edf0f8; box-shadow: none; z-index: 9; } @@ -7855,7 +5762,9 @@ _:-ms-fullscreen, font-weight: 400; color: #303b55; border-bottom: 1px solid #e7ebf3; - background: #fff; + // Transparent so the row's hover tint shows across every cell, not just the + // one under the cursor. + background: transparent; } .f-table .tbody .tr .td:hover { @@ -7928,20 +5837,6 @@ _:-ms-fullscreen, align-items: center; gap: 8px; } - - .connections-count-txt { - display: inline-flex; - align-items: center; - font-size: 12px; - font-weight: 600; - color: #5f6d8a; - line-height: 1; - border: 1px solid #dce4f3; - border-radius: 999px; - background: #f3f6fd; - padding: 7px 10px; - } - .connections-table-filters { align-items: center; gap: 8px; @@ -8006,27 +5901,6 @@ _:-ms-fullscreen, white-space: nowrap; } } - - .connections-rename-btn { - width: 24px; - height: 24px; - min-width: 24px; - min-height: 24px; - border-radius: 7px; - background: transparent; - color: #9246f7; - margin: 0; - - &:hover { - background: #efe4ff; - color: #6f26dc; - } - - &:active { - background: #e5d6ff; - } - } - .connections-edit-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; @@ -8152,11 +6026,9 @@ $pg-muted: #5f6d8a; $pg-bdr: #e4e9f4; $pg-bdr-strong: #d3dbec; $pg-surface: #ffffff; -$pg-canvas: #f8f9fd; $pg-accent: $purple; $pg-accent-soft: #f4ecff; $pg-danger-ink: #b32318; -$pg-danger-soft: #fdecea; $pg-danger-bdr: #eec3bd; $pg-ease: cubic-bezier(0.23, 1, 0.32, 1); @@ -8862,4 +6734,51 @@ $pg-ease: cubic-bezier(0.23, 1, 0.32, 1); .btcd-skel { animation: none; } + + .connection-loading-txt { + animation: none; + opacity: 1; + } +} + +/* ---------- connection select loading ---------- */ + +@keyframes btcd-conn-loading-in { + to { + opacity: 1; + } +} + +.connection-select-wrap { + .connection-loading-txt { + font-size: 12px; + font-weight: 500; + color: #5f6d8a; + line-height: 1; + opacity: 0; + animation: btcd-conn-loading-in 160ms cubic-bezier(0.23, 1, 0.32, 1) 180ms forwards; + } +} + +/* ---------- popover menus: reduced motion ---------- */ + +// Keeps the fade — it still explains that a panel opened — and drops the +// movement. Last in the file so it outranks each menu's own transform. +@media (prefers-reduced-motion: reduce) { + // The sweep is the whole effect here — there is no gentler version of it, so + // the panels just appear. + .btcd-menu-li, + .btcd-menu-a, + .btcd-bulk-menu-list, + .btcd-bulk-menu-list-open, + .ai-tool-dropdown, + .ai-tool-dropdown-open { + transition: visibility 0s; + } + + .btcd-bulk-menu-btn:active, + .btcd-columns-btn:active, + .tutoriallink-btn:active { + transform: none; + } } diff --git a/readme.txt b/readme.txt index 8826435de..81cb91c37 100644 --- a/readme.txt +++ b/readme.txt @@ -4,8 +4,9 @@ Tags: automation, automator, google sheets integration, form integration, WooCom Requires at least: 5.1 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 2.10.0 -License: GPLv2 or later +Stable tag: 2.10.1 +License: GPL-2.0-or-later +License URI: https://www.gnu.org/licenses/gpl-2.0.html Contact Form, Google Sheet, MailChimp, Brevo, Webhook, Zoho CRM Automation and Integration plugin that Connect 360+ platforms @@ -468,6 +469,40 @@ Bit Integrations follows WordPress coding standards and best practices to ensure == Changelog == += 2.10.1 = +_Release Date - 30th July 2026_ + +- **New Triggers** + - Bit CRM: 66 new events added. + - Fluent Player: 12 new events added (Pro). + +- **New Actions** + - Bit CRM: 50 new events added. + - Fluent Player: 26 new events added (Pro). + +- **New Feature** + - Webhook (Action): Dynamic URL path variables added to outgoing webhooks, mappable from trigger data. + - Webhook (Action): Smart codes are now available in query parameters, request headers and path variables (Pro). + - Connections: An action's connection can now be switched from its info page. + +- **Security Fixes** + - Custom Action: Closed an administrator gate bypass and confined the custom function file to the plugin's custom-function directory. + - Timeline: Log re-execution now applies the same administrator check as custom action save, update and delete. + - AJAX Routes: Integration routes now require a write capability instead of view-only access. + - Mail: Recipient addresses and headers are validated in all cases, and header display names are sanitized. + - Admin Menu: Submenu is registered only when the capability check passes. + +- **Bug Fixes** + - Connections: Fixed credentials not resolving for renamed integrations. + - Integration Info: Fixed legacy actions rendering a blank info page. + - Webhook (Action): Run status is now judged by the response HTTP status code, and path variables sync even when their tab is never opened. + - ACF: Fixed field reading when the meta value is missing (Pro). + - WP Post: Fixed trashed and internal posts firing the post created/inserted triggers (Pro). + +- **Note: "Legacy" integrations** + - Since 2.10.0, credentials live in reusable Connections. Integrations you set up before that still keep their own credentials, so we call them legacy - their info page shows a short explainer instead of a connection to view or switch. + - They keep working exactly as before, and credentials stay safe on your server (never sent to your browser, which is why the fields look empty). To move one over, just open its settings and authorize the app once. + = 2.10.0 = _Release Date - 25th July 2026_ diff --git a/views/emails/integration-failure-notification.php b/views/emails/integration-failure-notification.php index 319c3a035..af202607a 100644 --- a/views/emails/integration-failure-notification.php +++ b/views/emails/integration-failure-notification.php @@ -18,6 +18,11 @@ exit; } +// Template locals, not true globals - the file has no function scope, so PHPCS +// reads them as global. They already carry the plugin slug as their prefix; +// Plugin Check infers prefixes from hook names rather than the slug, and this +// plugin fires third-party hooks, so `bit_integrations` never makes its list. +// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound $bit_integrations_title = esc_html__('Integration Failure Alert', 'bit-integrations'); $bit_integrations_greeting = sprintf( // translators: %s: Placeholder value @@ -39,6 +44,7 @@ esc_html__('You received this email because failure notifications are enabled in %s. You can disable these notifications in the plugin settings.', 'bit-integrations'), 'Bit Integrations' ); +// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>