'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' );
?>
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 );
}