'api_key', // Field name (will be saved as-is) * 'label' => 'API Key', // Display label * 'type' => 'text', // Input type (text, password, select, textarea) * 'required' => true, // Whether field is required * 'placeholder' => '', // Optional placeholder * 'description' => '', // Optional help text * 'mask' => true, // Whether to mask in table (for sensitive data) * 'show_in_table' => true, // Whether to show in accounts table * 'options' => [] // For select fields: ['value' => 'Label'] * ] * @param string $instructions HTML content for instructions sidebar. * @param array $config Optional configuration: * [ * 'ajax_action_prefix' => 'adfoin', // AJAX action prefix * 'show_status' => false, // Show connection status column * 'custom_save_handler' => null, // Custom save callback * 'custom_delete_handler' => null, // Custom delete callback * ] */ public static function render_settings_view( $platform, $title, $fields, $instructions, $config = array() ) { // Default config $config = wp_parse_args( $config, array( 'ajax_action_prefix' => 'adfoin', 'show_status' => false, 'custom_save_handler' => null, 'custom_delete_handler' => null, ) ); // Read existing credentials $credentials = adfoin_read_credentials( $platform ); if ( ! is_array( $credentials ) ) { $credentials = array(); } $nonce = wp_create_nonce( 'advanced-form-integration' ); ?>

$cred ) : ?>


6 ) { return substr( $value, 0, 6 ) . '****'; } // For short values, show first 2 chars + asterisks if ( $length > 2 ) { return substr( $value, 0, 2 ) . str_repeat( '*', $length - 2 ); } return str_repeat( '*', $length ); } /** * Build a legacy-account record from old wp_options keys. * * Intended for use inside an `adfoin_get_credentials` filter callback to * surface a single legacy account when the new credentials store is empty. * Returns $credentials unchanged if not for this platform or already populated. * * @param array $credentials Current filter value. * @param string $platform Filter's $platform argument. * @param string $target_platform Slug this importer handles. * @param array $field_map [ new_field_name => legacy_option_key ] * or [ new_field_name => [ key1, key2, ... ] ] * (first non-empty key wins — supports * platforms that renamed an option over time). * @param array $args Optional: [ 'id' => 'legacy', 'title' => 'Legacy Account' ]. * @return array */ public static function import_legacy_option( $credentials, $platform, $target_platform, $field_map, $args = array() ) { if ( $platform !== $target_platform || ! empty( $credentials ) ) { return $credentials; } $args = wp_parse_args( $args, array( 'id' => 'legacy', 'title' => __( 'Legacy Account', 'advanced-form-integration' ), ) ); $record = array( 'id' => $args['id'], 'title' => $args['title'] ); $any = false; foreach ( $field_map as $new_field => $option_keys ) { $keys = is_array( $option_keys ) ? $option_keys : array( $option_keys ); $val = ''; foreach ( $keys as $option_key ) { $candidate = get_option( $option_key, '' ); if ( $candidate !== '' && $candidate !== false ) { $val = $candidate; break; } } if ( $val !== '' && $val !== false ) { $any = true; } $record[ $new_field ] = is_string( $val ) ? $val : ''; } if ( ! $any ) { return $credentials; } $credentials[] = $record; return $credentials; } /** * One-liner registration: hooks `adfoin_get_credentials` for $platform with * the supplied legacy option mapping. Replaces ~15 lines of per-platform * boilerplate. * * Example: * ADFOIN_Account_Manager::register_legacy_option_importer( 'activecampaign', array( * 'apiKey' => 'adfoin_activecampaign_api_key', * 'url' => 'adfoin_activecampaign_url', * ) ); * * @param string $platform Platform slug. * @param array $field_map [ new_field_name => legacy_option_key ]. * @param array $args Optional: [ 'id' => ..., 'title' => ... ]. */ public static function register_legacy_option_importer( $platform, $field_map, $args = array() ) { add_filter( 'adfoin_get_credentials', function( $credentials, $current_platform ) use ( $platform, $field_map, $args ) { return self::import_legacy_option( $credentials, $current_platform, $platform, $field_map, $args ); }, 10, 2 ); } /** * Handle AJAX request to save credentials. * This is a generic handler that can be used by all platforms. * * @param string $platform Platform slug. * @param array $field_names Array of field names to save. */ public static function ajax_save_credentials( $platform, $field_names ) { // Authorization check if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'advanced-form-integration' ) ), 403 ); } // Security check if ( ! wp_verify_nonce( wp_unslash( $_POST['_nonce'] ), 'advanced-form-integration' ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed', 'advanced-form-integration' ) ) ); } $credentials = adfoin_read_credentials( $platform ); if ( ! is_array( $credentials ) ) { $credentials = array(); } // Handle deletion if ( isset( $_POST['delete_index'] ) ) { $index = intval( wp_unslash( $_POST['delete_index'] ) ); if ( isset( $credentials[ $index ] ) ) { array_splice( $credentials, $index, 1 ); adfoin_save_credentials( $platform, $credentials ); wp_send_json_success( array( 'message' => __( 'Account deleted successfully', 'advanced-form-integration' ) ) ); } wp_send_json_error( array( 'message' => __( 'Account not found', 'advanced-form-integration' ) ) ); } // Handle save/update $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : ''; if ( empty( $id ) ) { $id = wp_generate_uuid4(); } $new_data = array( 'id' => $id, 'title' => $title, ); // Save all field values with type-aware sanitization. // $field_names accepts either: // [ 'apiKey', 'url' ] (back-compat — type inferred from the name) // [ 'apiKey' => 'password', 'url' => 'url' ] (typed) // XSS prevention is handled at output via esc_html()/esc_attr() in the render path. foreach ( $field_names as $key => $val ) { $name = is_int( $key ) ? $val : $key; $type = is_int( $key ) ? self::infer_field_type( $name ) : $val; $raw = isset( $_POST[ $name ] ) ? wp_unslash( $_POST[ $name ] ) : ''; $new_data[ $name ] = self::sanitize_field_value( $raw, $type ); } // Update existing or add new $found = false; foreach ( $credentials as &$cred ) { if ( $cred['id'] === $id ) { foreach ( $new_data as $k => $v ) { $cred[ $k ] = $v; } $found = true; break; } } if ( ! $found ) { $credentials[] = $new_data; } adfoin_save_credentials( $platform, $credentials ); wp_send_json_success( array( 'message' => __( 'Account saved successfully', 'advanced-form-integration' ), 'credentials' => $credentials ) ); } /** * Handle AJAX request to get credentials list (for Vue components). * Returns masked sensitive data. * * @param string $platform Platform slug. * @param array $fields Field definitions (to know which to mask). */ public static function ajax_get_credentials_list( $platform, $fields = array() ) { // Authorization check if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'advanced-form-integration' ) ), 403 ); } if ( ! wp_verify_nonce( wp_unslash( $_POST['_nonce'] ), 'advanced-form-integration' ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed', 'advanced-form-integration' ) ) ); } $credentials = adfoin_read_credentials( $platform ); $list = array(); if ( is_array( $credentials ) ) { foreach ( $credentials as $cred ) { $masked_cred = array( 'id' => $cred['id'], 'title' => isset( $cred['title'] ) ? $cred['title'] : __( 'Untitled', 'advanced-form-integration' ), ); // Mask sensitive fields foreach ( $fields as $field ) { $field_name = $field['name']; if ( isset( $cred[ $field_name ] ) ) { if ( ! empty( $field['mask'] ) ) { $masked_cred[ $field_name ] = self::mask_value( $cred[ $field_name ] ); } else { $masked_cred[ $field_name ] = $cred[ $field_name ]; } } } $list[] = $masked_cred; } } wp_send_json_success( $list ); } /** * Handle AJAX request to get full credentials (for editing). * * @param string $platform Platform slug. */ public static function ajax_get_credentials( $platform ) { // Authorization check if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'advanced-form-integration' ) ), 403 ); } if ( ! wp_verify_nonce( wp_unslash( $_POST['_nonce'] ), 'advanced-form-integration' ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed', 'advanced-form-integration' ) ) ); } $credentials = adfoin_read_credentials( $platform ); wp_send_json_success( $credentials ); } /** * Sanitize a single field value by type. * * Input sanitization just normalizes the stored shape; XSS prevention is * handled at output via esc_html()/esc_attr() in the render path. * * @param mixed $value Raw value from $_POST (already wp_unslash'd). * @param string $type Field type: 'text', 'password', 'token', 'url', * 'email', 'textarea', 'select'. Unknown values fall * through to 'text'. * @return string */ /** * Infer a field's sanitizer type from its name when the caller didn't * give one. Names that look like secrets (token, secret, password, * API/app key…) get the secret-preserving 'token' type; everything else * is plain text. * * @param string $name Field name. * @return string 'token' or 'text'. */ protected static function infer_field_type( $name ) { return preg_match( '/token|secret|password|pass|apikey|api_key|key$/i', (string) $name ) ? 'token' : 'text'; } protected static function sanitize_field_value( $value, $type = 'text' ) { if ( ! is_string( $value ) ) { return ''; } switch ( $type ) { case 'url': return esc_url_raw( trim( $value ) ); case 'email': return sanitize_email( $value ); case 'textarea': return sanitize_textarea_field( $value ); case 'password': case 'token': // API keys / tokens / secrets: preserve content exactly (incl. // quotes, ampersands, angle brackets, %XX sequences, and the // newlines of PEM keys or JSON service-account files). Only // surrounding whitespace and non-printing control characters // other than tab / newline / carriage return are removed. return preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', trim( $value ) ); case 'text': case 'select': default: return sanitize_text_field( $value ); } } /** * Translate the legacy `adfoin_platform_settings_template()` field shape * into the schema expected by render_settings_view(). * * Legacy shape: [ 'key' => 'apiKey', 'label' => 'API Key', 'hidden' => true ] * Modern shape: [ 'name' => 'apiKey', 'label' => '...', 'type' => 'password', * 'mask' => true, 'required' => true, 'show_in_table' => true ] * * @param array $legacy_fields * @return array */ public static function translate_legacy_fields( $legacy_fields ) { $fields = array(); if ( ! is_array( $legacy_fields ) ) { return $fields; } foreach ( $legacy_fields as $f ) { if ( empty( $f['key'] ) ) { continue; } $is_secret = ! empty( $f['hidden'] ); $label = isset( $f['label'] ) ? (string) $f['label'] : $f['key']; // Labels carrying "(optional)" should not force a required input. $is_required = ( false === stripos( $label, '(optional)' ) ); $fields[] = array( 'name' => $f['key'], 'label' => $label, 'type' => $is_secret ? 'password' : 'text', 'required' => $is_required, 'mask' => $is_secret, 'show_in_table' => true, ); } return $fields; } /** * Intercept legacy `adfoin_save__credentials` AJAX requests * issued by the new manager UI, before the platform's own bulk handler * runs. * * The legacy per-platform handler expects `$_POST['data']` (the full Vue * table) and silently discards a per-row submit. We detect the modern * shape (`title` or `delete_index` is present, `data` is not) and route * through ajax_save_credentials(), inferring field types from name. * * Fires on `admin_init` because that hook runs before the action-specific * `wp_ajax_` dispatch in admin-ajax.php. Calls wp_send_json_*, * which terminates execution, so the legacy handler never runs for * modern submissions. Old-style submissions (which the legacy UI is no * longer rendered to produce) pass through untouched. */ public static function legacy_save_bridge() { if ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) { return; } if ( empty( $_POST['action'] ) || ! is_string( $_POST['action'] ) ) { return; } if ( ! preg_match( '/^adfoin_save_([a-z0-9_]+)_credentials$/', $_POST['action'], $m ) ) { return; } $is_modern_save = isset( $_POST['title'] ) && ! isset( $_POST['data'] ); $is_modern_delete = isset( $_POST['delete_index'] ) && ! isset( $_POST['data'] ); if ( ! $is_modern_save && ! $is_modern_delete ) { return; } $platform = $m[1]; // CRITICAL: skip OAuth platforms. Each OAuth platform (mstodo, // googlesheets, zohocrm, salesforce, bigin, etc.) has its own // wp_ajax_adfoin_save__credentials handler that generates // an `auth_url` and returns it to the OAuth Manager UI so the popup // can navigate to the provider. If this bridge intercepts the // request first (admin_init priority 5 fires before wp_ajax_*), // it returns a plain "Account saved successfully" response WITHOUT // auth_url and the OAuth Manager JS closes the popup thinking it // was a non-OAuth save. The OAuth flow never starts. This was the // cause of the "popup closes immediately, no provider screen" bug. if ( self::is_oauth_platform( $platform ) ) { return; } /** * Let a platform with its own adfoin_save__credentials * handler (e.g. one that validates a token first) opt out of the bridge. * * @param bool $skip Default false. * @param string $platform Platform slug. */ if ( apply_filters( 'adfoin_skip_legacy_save_bridge', false, $platform ) ) { return; } // Reserved keys that aren't credential fields. $reserved = array( 'action', '_nonce', 'id', 'platform', 'title', 'delete_index' ); // Field types are inferred from the field names (see infer_field_type()). $field_names = array(); foreach ( array_keys( $_POST ) as $key ) { if ( in_array( $key, $reserved, true ) ) { continue; } $field_names[ $key ] = self::infer_field_type( $key ); } self::ajax_save_credentials( $platform, $field_names ); // ajax_save_credentials() calls wp_send_json_* which dies. } /** * Detect whether a platform slug is served by an OAuth Manager class * (i.e., a subclass of Advanced_Form_Integration_OAuth2). Used by * legacy_save_bridge() to avoid stomping on OAuth save_credentials * handlers that return auth_url. * * Walks declared classes once and caches the slug→is-oauth map for the * remainder of the request. Each OAuth platform sets * `protected $platform_slug = '';` at the class level, so the * default-properties reflection picks it up without instantiation. */ protected static function is_oauth_platform( $platform ) { static $cache = null; if ( null === $cache ) { $cache = array(); if ( class_exists( 'Advanced_Form_Integration_OAuth2' ) ) { foreach ( get_declared_classes() as $class ) { if ( $class === 'Advanced_Form_Integration_OAuth2' ) { continue; } if ( ! is_subclass_of( $class, 'Advanced_Form_Integration_OAuth2' ) ) { continue; } try { $reflection = new ReflectionClass( $class ); $defaults = $reflection->getDefaultProperties(); if ( ! empty( $defaults['platform_slug'] ) ) { $cache[ (string) $defaults['platform_slug'] ] = true; } } catch ( ReflectionException $e ) { // ignore — unreachable class metadata } } } } return isset( $cache[ $platform ] ); } } // Register the legacy bridge once, regardless of who loads the class. if ( ! has_action( 'admin_init', array( 'ADFOIN_Account_Manager', 'legacy_save_bridge' ) ) ) { add_action( 'admin_init', array( 'ADFOIN_Account_Manager', 'legacy_save_bridge' ), 5 ); }