Created
October 21, 2025 10:46
-
-
Save vfontjr/54be100be745c6d3aeade11f005f5e75 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Email OTP for Formidable Forms | |
| * - Generates and emails a 6-digit OTP tied to the submitted email. | |
| * - Stores OTP in a transient for 10 minutes. | |
| * - Validates on submit; blocks entry if code is wrong/expired. | |
| * | |
| * Replace FORM_ID, EMAIL_FIELD_ID, CODE_FIELD_ID with your actual IDs. | |
| */ | |
| const FF_OTP_FORM_ID = 11; // <-- your form ID | |
| const FF_OTP_EMAIL_FIELD = 25; // <-- your email field ID | |
| const FF_OTP_CODE_FIELD = 26; // <-- your code field ID | |
| const FF_OTP_LENGTH = 6; | |
| const FF_OTP_TTL_SECONDS = 10 * 60; // 10 minutes | |
| const FF_OTP_RATE_SECONDS = 60; // min seconds between sends to same email | |
| add_action('wp_enqueue_scripts', function () { | |
| // Localize admin-ajax endpoint + nonce for the frontend script below. | |
| wp_register_script('ff-otp-inline', '', [], null, true); | |
| wp_enqueue_script('ff-otp-inline'); | |
| wp_add_inline_script('ff-otp-inline', sprintf( | |
| 'window.ffOTP = { ajaxUrl: %s, nonce: %s, rate: %d };', | |
| json_encode(admin_url('admin-ajax.php')), | |
| json_encode(wp_create_nonce('ff_otp_nonce')), | |
| FF_OTP_RATE_SECONDS | |
| )); | |
| }); | |
| /** | |
| * AJAX: send OTP to provided email (no form submit required). | |
| */ | |
| add_action('wp_ajax_ff_send_otp', 'ff_send_otp'); | |
| add_action('wp_ajax_nopriv_ff_send_otp', 'ff_send_otp'); | |
| function ff_send_otp() { | |
| if (!check_ajax_referer('ff_otp_nonce', 'nonce', false)) { | |
| wp_send_json_error(['message' => 'Security check failed.'], 403); | |
| } | |
| $email = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : ''; | |
| if (!$email || !is_email($email)) { | |
| wp_send_json_error(['message' => 'Please enter a valid email address.'], 400); | |
| } | |
| $key = ff_otp_key($email); | |
| $existing = get_transient($key); | |
| // Simple rate-limit | |
| if (!empty($existing['last_sent']) && (time() - (int)$existing['last_sent']) < FF_OTP_RATE_SECONDS) { | |
| $wait = FF_OTP_RATE_SECONDS - (time() - (int)$existing['last_sent']); | |
| wp_send_json_error(['message' => "Please wait {$wait}s before requesting a new code."], 429); | |
| } | |
| $code = ff_rand_code(FF_OTP_LENGTH); | |
| $payload = [ | |
| 'code' => $code, | |
| 'expires' => time() + FF_OTP_TTL_SECONDS, | |
| 'last_sent' => time(), | |
| 'attempts' => 0, | |
| ]; | |
| set_transient($key, $payload, FF_OTP_TTL_SECONDS); | |
| $sent = wp_mail( | |
| $email, | |
| 'Your Verification Code', | |
| "Your verification code is: {$code}\n\nThis code expires in 10 minutes.", | |
| ['Content-Type: text/plain; charset=UTF-8'] | |
| ); | |
| if (!$sent) { | |
| wp_send_json_error(['message' => 'We could not send the email right now. Please try again later.'], 500); | |
| } | |
| wp_send_json_success(['message' => 'Verification code sent. Please check your inbox.']); | |
| } | |
| function ff_otp_key(string $email): string { | |
| return 'ff_otp_' . md5(strtolower(trim($email))); | |
| } | |
| function ff_rand_code(int $digits = 6): string { | |
| $min = (int) pow(10, $digits - 1); | |
| $max = (int) pow(10, $digits) - 1; | |
| return (string) wp_rand($min, $max); | |
| } | |
| /** | |
| * Validate the OTP on submit. | |
| * Use Formidable's per-field validation filter so errors show inline. | |
| */ | |
| add_filter('frm_validate_field_entry', function ($errors, $posted_field, $posted_value) { | |
| // Only enforce on our Code field, within our form | |
| if ((int) $posted_field->id !== FF_OTP_CODE_FIELD) { | |
| return $errors; | |
| } | |
| $form_id = (int) $posted_field->form_id; | |
| if ($form_id !== FF_OTP_FORM_ID) { | |
| return $errors; | |
| } | |
| $email_field_key = 'item_meta[' . FF_OTP_EMAIL_FIELD . ']'; | |
| $email = isset($_POST['item_meta'][FF_OTP_EMAIL_FIELD]) | |
| ? sanitize_email(wp_unslash($_POST['item_meta'][FF_OTP_EMAIL_FIELD])) | |
| : ''; | |
| $code = is_string($posted_value) ? trim($posted_value) : ''; | |
| if (!$email || !is_email($email)) { | |
| $errors['field' . FF_OTP_CODE_FIELD] = 'Please enter a valid email first and request a code.'; | |
| return $errors; | |
| } | |
| if ($code === '') { | |
| $errors['field' . FF_OTP_CODE_FIELD] = 'Enter the 6-digit verification code.'; | |
| return $errors; | |
| } | |
| $payload = get_transient(ff_otp_key($email)); | |
| if (!$payload || empty($payload['code']) || empty($payload['expires'])) { | |
| $errors['field' . FF_OTP_CODE_FIELD] = 'No active code found. Please click “Send Verification Code”.'; | |
| return $errors; | |
| } | |
| // Expired? | |
| if (time() > (int) $payload['expires']) { | |
| delete_transient(ff_otp_key($email)); | |
| $errors['field' . FF_OTP_CODE_FIELD] = 'That code expired. Please request a new one.'; | |
| return $errors; | |
| } | |
| // Match? | |
| if (!hash_equals((string) $payload['code'], (string) $code)) { | |
| $payload['attempts'] = (int) ($payload['attempts'] ?? 0) + 1; | |
| // Optional: lockout after N attempts by deleting transient or shortening TTL | |
| set_transient(ff_otp_key($email), $payload, (int) ($payload['expires'] - time())); | |
| $errors['field' . FF_OTP_CODE_FIELD] = 'Incorrect code. Please try again.'; | |
| return $errors; | |
| } | |
| // Success: clear the transient so the code can’t be reused | |
| delete_transient(ff_otp_key($email)); | |
| return $errors; | |
| }, 10, 3); | |
| /** | |
| * (Optional) Hard block at the entry-level as a failsafe. | |
| * If you prefer a single, form-level error instead of field-level, you can move validation here. | |
| */ | |
| add_filter('frm_entries_before_create', function ($errors, $form) { | |
| if ((int) $form->id !== FF_OTP_FORM_ID) { | |
| return $errors; | |
| } | |
| // If the per-field validator already added an error, we’re done. | |
| if (!empty($errors)) { | |
| return $errors; | |
| } | |
| return $errors; | |
| }, 30, 2); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script> | |
| document.addEventListener('click', async function (e) { | |
| if (e.target && e.target.id === 'ff-send-otp') { | |
| const status = document.getElementById('ff-otp-status'); | |
| const btn = e.target; | |
| // Update with your field selector; Formidable inputs commonly use #field_FIELDID | |
| const emailInput = document.querySelector('#field_<?= FF_OTP_EMAIL_FIELD ?>'); | |
| const email = emailInput ? emailInput.value.trim() : ''; | |
| if (!email) { | |
| status.textContent = 'Please enter your email address first.'; | |
| return; | |
| } | |
| btn.disabled = true; | |
| status.textContent = 'Sending...'; | |
| try { | |
| const res = await fetch(window.ffOTP.ajaxUrl, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, | |
| body: new URLSearchParams({ | |
| action: 'ff_send_otp', | |
| email: email, | |
| nonce: window.ffOTP.nonce | |
| }) | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| status.textContent = data.data.message; | |
| // Simple cooldown timer | |
| let remaining = window.ffOTP.rate || 60; | |
| const original = btn.textContent; | |
| const timer = setInterval(() => { | |
| btn.textContent = `Resend (${remaining}s)`; | |
| if (--remaining <= 0) { | |
| clearInterval(timer); | |
| btn.textContent = original; | |
| btn.disabled = false; | |
| } | |
| }, 1000); | |
| } else { | |
| status.textContent = data?.data?.message || 'Could not send the code.'; | |
| btn.disabled = false; | |
| } | |
| } catch (err) { | |
| status.textContent = 'Network error. Please try again.'; | |
| btn.disabled = false; | |
| } | |
| } | |
| }); | |
| </script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment