+
; ?>)
@@ -411,7 +461,7 @@ class="wpuf-gateway-card__checkbox"
-
@@ -998,22 +1048,22 @@ function(){
for (var field_name in dependencies) {
var expected_value = dependencies[field_name];
var $depends_on = $("input[id*='"+ field_name +"'], select[id*='"+ field_name +"']");
-
+
// If no field found with the simple selector, try more specific selectors
if ($depends_on.length === 0) {
$depends_on = $("input[name*='["+ field_name +"]'], select[name*='["+ field_name +"]']");
}
-
+
// If still no field found, try looking for the field name in the ID attribute
if ($depends_on.length === 0) {
$depends_on = $("input[id*='["+ field_name +"]'], select[id*='["+ field_name +"]']");
}
-
+
// If still no field found, try looking for the field name anywhere in the ID
if ($depends_on.length === 0) {
$depends_on = $("input[id*='"+ field_name +"'], select[id*='"+ field_name +"']");
}
-
+
if ($depends_on.length === 0) {
all_dependencies_met = false;
break;
@@ -1023,7 +1073,7 @@ function(){
var current_value = $depends_on.val();
var is_checkbox = $depends_on.attr('type') === 'checkbox';
var is_checked = is_checkbox ? $depends_on.is(':checked') : null;
-
+
// For checkboxes, handle empty string as "checked" expectation
var value_matches = false;
if (is_checkbox) {
@@ -1037,7 +1087,7 @@ function(){
} else {
value_matches = current_value === expected_value;
}
-
+
if (!value_matches) {
all_dependencies_met = false;
}
@@ -1073,26 +1123,26 @@ function checkAllDependencies() {
for (var field_name in dependencies) {
var expected_value = dependencies[field_name];
var $depends_on = $("input[id*='"+ field_name +"'], select[id*='"+ field_name +"']");
-
+
// If no field found with the simple selector, try more specific selectors
if ($depends_on.length === 0) {
$depends_on = $("input[name*='["+ field_name +"]'], select[name*='["+ field_name +"]']");
}
-
+
// If still no field found, try looking for the field name in the ID attribute
if ($depends_on.length === 0) {
$depends_on = $("input[id*='["+ field_name +"]'], select[id*='["+ field_name +"]']");
}
-
+
// If still no field found, try looking for the field name anywhere in the ID
if ($depends_on.length === 0) {
$depends_on = $("input[id*='"+ field_name +"'], select[id*='"+ field_name +"']");
}
-
+
var current_value = $depends_on.length > 0 ? $depends_on.val() : 'NOT_FOUND';
var is_checkbox = $depends_on.length > 0 && $depends_on.attr('type') === 'checkbox';
var is_checked = is_checkbox ? $depends_on.is(':checked') : null;
-
+
// For checkboxes, handle empty string as "checked" expectation
var value_matches = false;
if ($depends_on.length === 0) {
@@ -1108,13 +1158,13 @@ function checkAllDependencies() {
} else {
value_matches = current_value === expected_value;
}
-
+
if (!value_matches) {
all_met = false;
break;
}
}
-
+
if (all_met) {
$this.closest('tr').show();
} else {
diff --git a/assets/css/admin.css b/assets/css/admin.css
index 7c1b94ca3..e054bc6ad 100644
--- a/assets/css/admin.css
+++ b/assets/css/admin.css
@@ -1502,12 +1502,14 @@ body.wpuf-modal-open {
.wpuf-login-settings-row.hidden {
display: none !important;
}
-.wpuf-gateway-cards {
+.wpuf-gateway-cards,
+.wpuf-method-cards {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
-.wpuf-gateway-card {
+.wpuf-gateway-card,
+.wpuf-method-card {
position: relative;
display: flex;
flex-direction: column;
@@ -1521,32 +1523,39 @@ body.wpuf-modal-open {
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
-.wpuf-gateway-card:hover {
+.wpuf-gateway-card:hover,
+.wpuf-method-card:hover {
border-color: #c3c4c7;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
-.wpuf-gateway-card--active:hover {
+.wpuf-gateway-card--active:hover,
+.wpuf-method-card--active:hover {
border-color: #10b981;
}
-.wpuf-gateway-card--focused {
+.wpuf-gateway-card--focused,
+.wpuf-method-card--focused {
border-color: #10b981;
box-shadow: 0 0 0 1px #10b981;
}
-.wpuf-gateway-card--focused:hover {
+.wpuf-gateway-card--focused:hover,
+.wpuf-method-card--focused:hover {
border-color: #10b981;
}
-.wpuf-gateway-card--pro-locked {
+.wpuf-gateway-card--pro-locked,
+.wpuf-method-card--pro-locked {
opacity: 0.6;
cursor: not-allowed;
}
-.wpuf-gateway-card__checkbox {
+.wpuf-gateway-card__checkbox,
+.wpuf-method-card__checkbox {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
-.wpuf-gateway-card__toggle {
+.wpuf-gateway-card__toggle,
+.wpuf-method-card__toggle {
position: absolute;
top: 4px;
right: 4px;
@@ -1555,22 +1564,28 @@ body.wpuf-modal-open {
padding: 4px;
z-index: 2;
}
-.wpuf-gateway-card__toggle:hover {
+.wpuf-gateway-card__toggle:hover,
+.wpuf-method-card__toggle:hover {
opacity: 0.8;
}
-.wpuf-gateway-card__check-on {
+.wpuf-gateway-card__check-on,
+.wpuf-method-card__check-on {
display: none;
}
-.wpuf-gateway-card__check-off {
+.wpuf-gateway-card__check-off,
+.wpuf-method-card__check-off {
display: block;
}
-.wpuf-gateway-card--active .wpuf-gateway-card__check-on {
+.wpuf-gateway-card--active .wpuf-gateway-card__check-on,
+.wpuf-method-card--active .wpuf-method-card__check-on {
display: block;
}
-.wpuf-gateway-card--active .wpuf-gateway-card__check-off {
+.wpuf-gateway-card--active .wpuf-gateway-card__check-off,
+.wpuf-method-card--active .wpuf-method-card__check-off {
display: none;
}
-.wpuf-gateway-card__icon {
+.wpuf-gateway-card__icon,
+.wpuf-method-card__icon {
display: flex;
align-items: center;
justify-content: center;
@@ -1578,16 +1593,19 @@ body.wpuf-modal-open {
height: 48px;
margin-bottom: 8px;
}
-.wpuf-gateway-card__icon img {
+.wpuf-gateway-card__icon img,
+.wpuf-method-card__icon img {
max-width: 100px;
max-height: 48px;
object-fit: contain;
}
-.wpuf-gateway-card__icon svg {
+.wpuf-gateway-card__icon svg,
+.wpuf-method-card__icon svg {
max-width: 48px;
max-height: 48px;
}
-.wpuf-gateway-card__name {
+.wpuf-gateway-card__name,
+.wpuf-method-card__name {
font-size: 13px;
font-weight: 500;
color: #1d2327;
diff --git a/assets/css/wpuf-2fa-login.css b/assets/css/wpuf-2fa-login.css
new file mode 100644
index 000000000..7cf464fe9
--- /dev/null
+++ b/assets/css/wpuf-2fa-login.css
@@ -0,0 +1,75 @@
+/*
+ * WPUF — 2FA login challenge styles
+ *
+ * Loaded conditionally by Login_Controller::render_2fa_field() only when
+ * a 2FA challenge or method-picker stage is being rendered inside the
+ * [wpuf-login] form.
+ *
+ * Two concerns:
+ * 1. Hide the credentials row + remember-me + submit button while a
+ * challenge / picker is in flight. The form still posts to itself;
+ * stage 2 reads the hidden token, the credentials are no longer
+ * needed.
+ * 2. Self-contained method-picker styles. Themes vary wildly in how
+ * they style #loginform inputs/labels, so visibility and layout
+ * are locked with !important to keep radios + labels legible on
+ * any theme.
+ */
+
+/* ---- 1. Hide credential row during challenge / picker ---- */
+
+#loginform .wpuf-login-form > p:has(#wpuf-user_login),
+#loginform .wpuf-login-form > p:has(#wpuf-user_pass),
+#loginform > p:has(#wpuf-user_login),
+#loginform > p:has(#wpuf-user_pass),
+#loginform p.submit,
+.wpuf-login-form .forgetmenot {
+ display: none !important;
+}
+
+/* ---- 2. Method-picker layout ---- */
+
+.wpuf-2fa-method-picker {
+ display: block !important;
+ margin: 1em 0 !important;
+}
+
+.wpuf-2fa-method-picker__option {
+ display: block !important;
+ margin-bottom: .75em !important;
+}
+
+.wpuf-2fa-method-picker__option label {
+ display: inline-flex !important;
+ align-items: center !important;
+ gap: .5em !important;
+ color: inherit !important;
+ cursor: pointer !important;
+}
+
+.wpuf-2fa-method-picker__radio {
+ display: inline-block !important;
+ width: auto !important;
+ height: auto !important;
+ margin: 0 !important;
+ opacity: 1 !important;
+ position: static !important;
+ visibility: visible !important;
+}
+
+.wpuf-2fa-method-picker__label {
+ display: inline !important;
+ color: inherit !important;
+ font-weight: 600 !important;
+}
+
+.wpuf-2fa-method-picker__hint {
+ display: block !important;
+ color: #666 !important;
+ font-size: .9em !important;
+ margin: .15em 0 0 1.85em !important;
+}
+
+.wpuf-2fa-method-picker__actions {
+ margin-top: 1em !important;
+}
diff --git a/assets/js/admin/settings.js b/assets/js/admin/settings.js
index 964533b21..534bb7424 100644
--- a/assets/js/admin/settings.js
+++ b/assets/js/admin/settings.js
@@ -62,6 +62,9 @@
// Gateway Selector Card Grid
wpufInitGatewaySelector();
+ // Method Selector Card Grid (2FA, social login, etc.)
+ wpufInitMethodSelector();
+
function wpuf_search_reset() {
content.forEach(function (row, index) {
var content_id = row.closest('div').getAttribute('id');
@@ -304,4 +307,62 @@
hideAllGatewaySettings();
}
}
+
+ /**
+ * Method Selector Card Grid
+ *
+ * Generic toggle handler for the `method_selector` field type.
+ * Unlike the gateway selector, methods do not have per-method
+ * settings rows to reveal — clicking a card simply flips the
+ * checkbox and the `--active` class. Pro-locked cards are inert.
+ */
+ function wpufInitMethodSelector() {
+ var containers = document.querySelectorAll('.wpuf-method-cards');
+
+ if ( ! containers.length ) {
+ return;
+ }
+
+ containers.forEach(function(container) {
+ var cards = container.querySelectorAll('.wpuf-method-card');
+
+ cards.forEach(function(card) {
+ var checkbox = card.querySelector('.wpuf-method-card__checkbox');
+
+ if ( ! checkbox ) {
+ return;
+ }
+
+ // Sync `--active` when the checkbox state changes (label click,
+ // keyboard, or programmatic toggle).
+ checkbox.addEventListener('change', function() {
+ if ( checkbox.checked ) {
+ card.classList.add('wpuf-method-card--active');
+ } else {
+ card.classList.remove('wpuf-method-card--active');
+ }
+ });
+
+ // Card body click toggles the checkbox. The toggle label has its
+ // own `for` binding so we skip clicks that originate inside it
+ // to avoid double-firing.
+ card.addEventListener('click', function(e) {
+ if ( e.target.closest('.wpuf-method-card__toggle') ) {
+ return;
+ }
+
+ if ( e.target === checkbox ) {
+ return;
+ }
+
+ if ( card.classList.contains('wpuf-method-card--pro-locked') ) {
+ return;
+ }
+
+ checkbox.checked = ! checkbox.checked;
+ checkbox.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ });
+ });
+ }
})();
diff --git a/assets/js/wpuf-2fa-account.js b/assets/js/wpuf-2fa-account.js
new file mode 100644
index 000000000..9d240c09b
--- /dev/null
+++ b/assets/js/wpuf-2fa-account.js
@@ -0,0 +1,251 @@
+/**
+ * WPUF Two-Factor Authentication — Account Security tab
+ *
+ * Drives enrollment, confirmation, code-issuance, and self-disable flows
+ * on the [wpuf_account] Security tab. Generic across methods: every
+ * card has a data-method-id attribute and the four AJAX actions take
+ * method_id as a parameter. Method-specific extras (TOTP QR code,
+ * Email OTP masked destination, etc.) come from the props returned by
+ * Method_Interface::start_enrollment() and are rendered client-side
+ * via the renderEnrollmentExtras() dispatcher below.
+ *
+ * Methods that need a fresh code before self-disable (Email OTP, SMS
+ * OTP) are detected via the presence of `.wpuf-2fa-issue-disable-code`
+ * being unhidden by the renderer; TOTP keeps it hidden because the
+ * code is always available in the user's authenticator app.
+ */
+(function ($) {
+ 'use strict';
+
+ $(function () {
+ var $root = $('#wpuf-2fa-security');
+
+ if (!$root.length) {
+ return;
+ }
+
+ var ajaxUrl = $root.data('ajax-url');
+ var nonce = $root.data('nonce');
+
+ function showMessage($container, message, isError) {
+ var $msg = $container.find('.wpuf-2fa-message').first();
+ $msg.text(message || '');
+ $msg.removeClass('wpuf-text-red-600 wpuf-text-green-700');
+ $msg.addClass(isError ? 'wpuf-text-red-600' : 'wpuf-text-green-700');
+ }
+
+ function methodIdFor($card) {
+ return $card.data('method-id') || $card.attr('data-method-id') || '';
+ }
+
+ // --- Enrollment extras renderers ----------------------------------
+ // Per-method renderers fill `.wpuf-2fa-enrollment-extras` based on
+ // whatever start_enrollment() returned. Each method gets one
+ // function; default falls back to a generic instructions blurb.
+ var extrasRenderers = {
+ totp: function ($extras, props) {
+ var html = '';
+ html += '
';
+ html += '
' + (props.qr_svg || '') + '
';
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+ html += '
';
+ $extras.html(html);
+ },
+ // Default for any method we don't have a custom renderer for.
+ // The textual `note` is rendered separately into
+ // `.wpuf-2fa-enrollment-instructions` by the calling code, so
+ // we leave `$extras` empty here. Methods that need dynamic
+ // UI (QR codes, masked-destination fields) provide their
+ // own renderer keyed by method id.
+ _default: function ($extras /*, props */) {
+ $extras.empty();
+ }
+ };
+
+ function renderEnrollmentExtras(methodId, $extras, props) {
+ var renderer = extrasRenderers[methodId] || extrasRenderers._default;
+ renderer($extras, props || {});
+ }
+
+ function escapeHtml(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function escapeAttr(s) {
+ return escapeHtml(s);
+ }
+
+ // --- Enrollment: start --------------------------------------------
+ $root.on('click', '.wpuf-2fa-method-start', function (e) {
+ e.preventDefault();
+ var $btn = $(this);
+ var $card = $btn.closest('.wpuf-2fa-method-card');
+ var methodId = methodIdFor($card);
+ var $enroll = $card.find('.wpuf-2fa-method-enrollment');
+
+ $btn.prop('disabled', true);
+
+ $.post(ajaxUrl, {
+ action: 'wpuf_2fa_method_start',
+ _wpnonce: nonce,
+ method_id: methodId
+ }).done(function (response) {
+ if (!response || !response.success) {
+ var msg = (response && response.data && response.data.message)
+ ? response.data.message
+ : 'Could not start setup.';
+ showMessage($enroll, msg, true);
+ $btn.prop('disabled', false);
+ return;
+ }
+
+ var $extras = $enroll.find('.wpuf-2fa-enrollment-extras');
+ renderEnrollmentExtras(methodId, $extras, response.data);
+
+ if (response.data && response.data.note) {
+ $enroll.find('.wpuf-2fa-enrollment-instructions').text(response.data.note);
+ } else {
+ $enroll.find('.wpuf-2fa-enrollment-instructions').empty();
+ }
+
+ $enroll.removeClass('wpuf-hidden');
+ $enroll.find('.wpuf-2fa-method-code').focus();
+ }).fail(function () {
+ showMessage($enroll, 'Network error. Please try again.', true);
+ $btn.prop('disabled', false);
+ });
+ });
+
+ // --- Enrollment: confirm ------------------------------------------
+ $root.on('click', '.wpuf-2fa-method-confirm', function (e) {
+ e.preventDefault();
+ var $btn = $(this);
+ var $card = $btn.closest('.wpuf-2fa-method-card');
+ var methodId = methodIdFor($card);
+ var $enroll = $card.find('.wpuf-2fa-method-enrollment');
+ var code = $enroll.find('.wpuf-2fa-method-code').val();
+
+ if (!code) {
+ showMessage($enroll, 'Enter the verification code.', true);
+ return;
+ }
+
+ $btn.prop('disabled', true);
+
+ $.post(ajaxUrl, {
+ action: 'wpuf_2fa_method_confirm',
+ _wpnonce: nonce,
+ method_id: methodId,
+ code: code
+ }).done(function (response) {
+ if (!response || !response.success) {
+ var msg = (response && response.data && response.data.message)
+ ? response.data.message
+ : 'Could not verify the code.';
+ showMessage($enroll, msg, true);
+ $btn.prop('disabled', false);
+
+ if (response && response.data && response.data.expired) {
+ // Pending state expired — bounce back to the start.
+ $enroll.addClass('wpuf-hidden');
+ $card.find('.wpuf-2fa-method-start').prop('disabled', false);
+ }
+ return;
+ }
+
+ showMessage($enroll, response.data.message, false);
+ window.setTimeout(function () { window.location.reload(); }, 800);
+ }).fail(function () {
+ showMessage($enroll, 'Network error. Please try again.', true);
+ $btn.prop('disabled', false);
+ });
+ });
+
+ // --- Issue a disable code (Email OTP, SMS OTP) --------------------
+ $root.on('click', '.wpuf-2fa-issue-disable-code', function (e) {
+ e.preventDefault();
+ var $btn = $(this);
+ var $card = $btn.closest('.wpuf-2fa-method-card');
+ var methodId = methodIdFor($card);
+ var $form = $card.find('.wpuf-2fa-disable-form');
+
+ $btn.prop('disabled', true);
+
+ $.post(ajaxUrl, {
+ action: 'wpuf_2fa_method_issue',
+ _wpnonce: nonce,
+ method_id: methodId
+ }).done(function (response) {
+ $btn.prop('disabled', false);
+ if (!response || !response.success) {
+ var msg = (response && response.data && response.data.message)
+ ? response.data.message
+ : 'Could not send the code.';
+ showMessage($form, msg, true);
+ return;
+ }
+ showMessage($form, response.data.message, false);
+ }).fail(function () {
+ showMessage($form, 'Network error. Please try again.', true);
+ $btn.prop('disabled', false);
+ });
+ });
+
+ // --- Disable -------------------------------------------------------
+ $root.on('click', '.wpuf-2fa-method-disable', function (e) {
+ e.preventDefault();
+ var $btn = $(this);
+ var $card = $btn.closest('.wpuf-2fa-method-card');
+ var methodId = methodIdFor($card);
+ var $form = $card.find('.wpuf-2fa-disable-form');
+ var password = $form.find('.wpuf-2fa-disable-password').val();
+ var code = $form.find('.wpuf-2fa-disable-code').val();
+
+ if (!password) {
+ showMessage($form, 'Enter your current password.', true);
+ return;
+ }
+ if (!code) {
+ showMessage($form, 'Enter the verification code.', true);
+ return;
+ }
+
+ $btn.prop('disabled', true);
+
+ $.post(ajaxUrl, {
+ action: 'wpuf_2fa_method_disable',
+ _wpnonce: nonce,
+ method_id: methodId,
+ password: password,
+ code: code
+ }).done(function (response) {
+ if (!response || !response.success) {
+ var msg = (response && response.data && response.data.message)
+ ? response.data.message
+ : 'Could not disable 2FA.';
+ showMessage($form, msg, true);
+ $btn.prop('disabled', false);
+ return;
+ }
+
+ showMessage($form, response.data.message, false);
+ window.setTimeout(function () { window.location.reload(); }, 800);
+ }).fail(function () {
+ showMessage($form, 'Network error. Please try again.', true);
+ $btn.prop('disabled', false);
+ });
+ });
+ });
+
+})(jQuery);
diff --git a/assets/js/wpuf-2fa-login.js b/assets/js/wpuf-2fa-login.js
new file mode 100644
index 000000000..341928020
--- /dev/null
+++ b/assets/js/wpuf-2fa-login.js
@@ -0,0 +1,46 @@
+/*
+ * WPUF — 2FA login method-picker auto-advance
+ *
+ * Loaded conditionally by Login_Controller::render_2fa_field() only on
+ * the picker stage. Submits the form when the user clicks / keys a
+ * radio so a single interaction advances to the code-entry screen.
+ *
+ * Gated by a real user gesture (pointerdown / keydown) so we don't
+ * submit on page load — themes / autofill / late-loaded scripts can
+ * otherwise fire `change` on the pre-checked radio and trigger an
+ * unwanted submit. The Continue button remains the no-JS fallback.
+ */
+
+( function () {
+ 'use strict';
+
+ var radios = document.querySelectorAll( '.wpuf-2fa-method-picker__radio' );
+
+ if ( ! radios.length ) {
+ return;
+ }
+
+ var userInteracted = false;
+
+ Array.prototype.forEach.call( radios, function ( radio ) {
+ radio.addEventListener( 'pointerdown', function () {
+ userInteracted = true;
+ } );
+
+ radio.addEventListener( 'keydown', function () {
+ userInteracted = true;
+ } );
+
+ radio.addEventListener( 'change', function () {
+ if ( ! userInteracted ) {
+ return;
+ }
+
+ var form = radio.closest( 'form' );
+
+ if ( form ) {
+ form.submit();
+ }
+ } );
+ } );
+} )();
diff --git a/assets/less/admin.less b/assets/less/admin.less
index c6970462e..e7d0e7f17 100644
--- a/assets/less/admin.less
+++ b/assets/less/admin.less
@@ -1536,16 +1536,22 @@ body.wpuf-modal-open {
}
// ==========================================================================
-// Gateway Selector Card Grid
+// Card Selector Grid
// ==========================================================================
+// Shared by `gateway_selector` and `method_selector` field types. Each
+// emits its own BEM block (`wpuf-gateway-card`, `wpuf-method-card`) so
+// the two namespaces evolve independently while reusing the same rules.
+// Add new selector blocks to both selector lists below.
-.wpuf-gateway-cards {
+.wpuf-gateway-cards,
+.wpuf-method-cards {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
-.wpuf-gateway-card {
+.wpuf-gateway-card,
+.wpuf-method-card {
position: relative;
display: flex;
flex-direction: column;
@@ -1563,92 +1569,103 @@ body.wpuf-modal-open {
border-color: #c3c4c7;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
+}
- &--active:hover {
- border-color: #10b981;
- }
+.wpuf-gateway-card--active:hover,
+.wpuf-method-card--active:hover {
+ border-color: #10b981;
+}
- &--focused {
- border-color: #10b981;
- box-shadow: 0 0 0 1px #10b981;
+.wpuf-gateway-card--focused,
+.wpuf-method-card--focused {
+ border-color: #10b981;
+ box-shadow: 0 0 0 1px #10b981;
- &:hover {
- border-color: #10b981;
- }
- }
-
- &--pro-locked {
- opacity: 0.6;
- cursor: not-allowed;
+ &:hover {
+ border-color: #10b981;
}
+}
- &__checkbox {
- position: absolute;
- opacity: 0;
- width: 0;
- height: 0;
- pointer-events: none;
- }
+.wpuf-gateway-card--pro-locked,
+.wpuf-method-card--pro-locked {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
- &__toggle {
- position: absolute;
- top: 4px;
- right: 4px;
- cursor: pointer;
- line-height: 1;
- padding: 4px;
- z-index: 2;
+.wpuf-gateway-card__checkbox,
+.wpuf-method-card__checkbox {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ pointer-events: none;
+}
- &:hover {
- opacity: 0.8;
- }
- }
+.wpuf-gateway-card__toggle,
+.wpuf-method-card__toggle {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ cursor: pointer;
+ line-height: 1;
+ padding: 4px;
+ z-index: 2;
- &__check-on {
- display: none;
+ &:hover {
+ opacity: 0.8;
}
+}
- &__check-off {
- display: block;
- }
+.wpuf-gateway-card__check-on,
+.wpuf-method-card__check-on {
+ display: none;
+}
- &--active &__check-on {
- display: block;
- }
+.wpuf-gateway-card__check-off,
+.wpuf-method-card__check-off {
+ display: block;
+}
- &--active &__check-off {
- display: none;
- }
+.wpuf-gateway-card--active .wpuf-gateway-card__check-on,
+.wpuf-method-card--active .wpuf-method-card__check-on {
+ display: block;
+}
- &__icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 48px;
- margin-bottom: 8px;
+.wpuf-gateway-card--active .wpuf-gateway-card__check-off,
+.wpuf-method-card--active .wpuf-method-card__check-off {
+ display: none;
+}
- img {
- max-width: 100px;
- max-height: 48px;
- object-fit: contain;
- }
+.wpuf-gateway-card__icon,
+.wpuf-method-card__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 48px;
+ margin-bottom: 8px;
- svg {
- max-width: 48px;
- max-height: 48px;
- }
+ img {
+ max-width: 100px;
+ max-height: 48px;
+ object-fit: contain;
}
- &__name {
- font-size: 13px;
- font-weight: 500;
- color: #1d2327;
- text-align: center;
- line-height: 1.3;
+ svg {
+ max-width: 48px;
+ max-height: 48px;
}
}
+.wpuf-gateway-card__name,
+.wpuf-method-card__name {
+ font-size: 13px;
+ font-weight: 500;
+ color: #1d2327;
+ text-align: center;
+ line-height: 1.3;
+}
+
// Gateway settings rows shown/hidden by JS
tr.wpuf-gateway-setting-row {
transition: opacity 0.15s ease;
diff --git a/composer.json b/composer.json
index e30e84050..1a18c87a6 100644
--- a/composer.json
+++ b/composer.json
@@ -10,10 +10,13 @@
}
],
"minimum-stability": "dev",
+ "prefer-stable": true,
"require": {
"php": ">=7.4",
"composer/installers": ">=1.4",
- "wedevs/wp-utils": "dev-main"
+ "wedevs/wp-utils": "dev-main",
+ "pragmarx/google2fa": "^8.0.3",
+ "bacon/bacon-qr-code": "^2.0"
},
"require-dev": {
"wp-coding-standards/wpcs": "dev-develop",
diff --git a/composer.lock b/composer.lock
index cc79b25cc..139cf6585 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,62 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "1fa30eae591844494a2c8a0926763809",
+ "content-hash": "1c82807f56b1fbdaa4d9dc598dc052e8",
"packages": [
+ {
+ "name": "bacon/bacon-qr-code",
+ "version": "2.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Bacon/BaconQrCode.git",
+ "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
+ "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
+ "shasum": ""
+ },
+ "require": {
+ "dasprid/enum": "^1.0.3",
+ "ext-iconv": "*",
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "phly/keep-a-changelog": "^2.1",
+ "phpunit/phpunit": "^7 | ^8 | ^9",
+ "spatie/phpunit-snapshot-assertions": "^4.2.9",
+ "squizlabs/php_codesniffer": "^3.4"
+ },
+ "suggest": {
+ "ext-imagick": "to generate QR code images"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "BaconQrCode\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "email": "mail@dasprids.de",
+ "homepage": "https://dasprids.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "BaconQrCode is a QR code generator for PHP.",
+ "homepage": "https://github.com/Bacon/BaconQrCode",
+ "support": {
+ "issues": "https://github.com/Bacon/BaconQrCode/issues",
+ "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
+ },
+ "time": "2022-12-07T17:46:57+00:00"
+ },
{
"name": "composer/installers",
"version": "dev-main",
@@ -152,6 +206,177 @@
],
"time": "2024-11-26T13:24:01+00:00"
},
+ {
+ "name": "dasprid/enum",
+ "version": "1.0.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/DASPRiD/Enum.git",
+ "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1 <9.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
+ "squizlabs/php_codesniffer": "*"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "DASPRiD\\Enum\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "email": "mail@dasprids.de",
+ "homepage": "https://dasprids.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "PHP 7.1 enum implementation",
+ "keywords": [
+ "enum",
+ "map"
+ ],
+ "support": {
+ "issues": "https://github.com/DASPRiD/Enum/issues",
+ "source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
+ },
+ "time": "2025-09-16T12:23:56+00:00"
+ },
+ {
+ "name": "paragonie/constant_time_encoding",
+ "version": "v3.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/paragonie/constant_time_encoding.git",
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8"
+ },
+ "require-dev": {
+ "infection/infection": "^0",
+ "nikic/php-fuzzer": "^0",
+ "phpunit/phpunit": "^9|^10|^11",
+ "vimeo/psalm": "^4|^5|^6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ParagonIE\\ConstantTime\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Paragon Initiative Enterprises",
+ "email": "security@paragonie.com",
+ "homepage": "https://paragonie.com",
+ "role": "Maintainer"
+ },
+ {
+ "name": "Steve 'Sc00bz' Thomas",
+ "email": "steve@tobtu.com",
+ "homepage": "https://www.tobtu.com",
+ "role": "Original Developer"
+ }
+ ],
+ "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
+ "keywords": [
+ "base16",
+ "base32",
+ "base32_decode",
+ "base32_encode",
+ "base64",
+ "base64_decode",
+ "base64_encode",
+ "bin2hex",
+ "encoding",
+ "hex",
+ "hex2bin",
+ "rfc4648"
+ ],
+ "support": {
+ "email": "info@paragonie.com",
+ "issues": "https://github.com/paragonie/constant_time_encoding/issues",
+ "source": "https://github.com/paragonie/constant_time_encoding"
+ },
+ "time": "2025-09-24T15:06:41+00:00"
+ },
+ {
+ "name": "pragmarx/google2fa",
+ "version": "v8.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/antonioribeiro/google2fa.git",
+ "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
+ "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad",
+ "shasum": ""
+ },
+ "require": {
+ "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
+ "php": "^7.1|^8.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.9",
+ "phpunit/phpunit": "^7.5.15|^8.5|^9.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PragmaRX\\Google2FA\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Antonio Carlos Ribeiro",
+ "email": "acr@antoniocarlosribeiro.com",
+ "role": "Creator & Designer"
+ }
+ ],
+ "description": "A One Time Password Authentication package, compatible with Google Authenticator.",
+ "keywords": [
+ "2fa",
+ "Authentication",
+ "Two Factor Authentication",
+ "google2fa"
+ ],
+ "support": {
+ "issues": "https://github.com/antonioribeiro/google2fa/issues",
+ "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3"
+ },
+ "time": "2024-09-05T11:56:40+00:00"
+ },
{
"name": "wedevs/wp-utils",
"version": "dev-main",
@@ -2588,16 +2813,16 @@
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
- "wedevs/wp-utils": 20,
- "wp-coding-standards/wpcs": 20,
+ "phpcompatibility/phpcompatibility-wp": 20,
"tareq1988/wp-php-cs-fixer": 20,
- "phpcompatibility/phpcompatibility-wp": 20
+ "wedevs/wp-utils": 20,
+ "wp-coding-standards/wpcs": 20
},
- "prefer-stable": false,
+ "prefer-stable": true,
"prefer-lowest": false,
"platform": {
- "php": ">=5.5"
+ "php": ">=7.4"
},
- "platform-dev": [],
+ "platform-dev": {},
"plugin-api-version": "2.6.0"
}
diff --git a/includes/Assets.php b/includes/Assets.php
index 278972d3f..0e7290bc4 100644
--- a/includes/Assets.php
+++ b/includes/Assets.php
@@ -194,6 +194,9 @@ public function get_styles() {
'account' => [
'src' => WPUF_ASSET_URI . '/css/frontend/account.css',
],
+ '2fa-login' => [
+ 'src' => WPUF_ASSET_URI . '/css/wpuf-2fa-login.css',
+ ],
];
return apply_filters( 'wpuf_styles_to_register', $styles );
@@ -422,6 +425,15 @@ public function get_scripts() {
'src' => WPUF_ASSET_URI . '/js/elementor-subscription-plans.js',
'in_footer' => true,
],
+ '2fa-account' => [
+ 'src' => WPUF_ASSET_URI . '/js/wpuf-2fa-account.js',
+ 'deps' => [ 'jquery' ],
+ 'in_footer' => true,
+ ],
+ '2fa-login' => [
+ 'src' => WPUF_ASSET_URI . '/js/wpuf-2fa-login.js',
+ 'in_footer' => true,
+ ],
];
if ( ! empty( $api_key ) ) {
diff --git a/includes/Free/Simple_Login.php b/includes/Free/Simple_Login.php
index 59a18a474..afc9a948a 100644
--- a/includes/Free/Simple_Login.php
+++ b/includes/Free/Simple_Login.php
@@ -49,6 +49,18 @@ public function __construct() {
add_filter( 'login_form_login', [ $this, 'default_wp_login_override' ] );
add_filter( 'authenticate', [ $this, 'successfully_authenticate' ], 30, 3 );
+
+ // Default Turnstile verifier — extensions can replace by hooking
+ // `wpuf_login_turnstile_verify` at a higher priority and returning
+ // their own result, or remove this filter entirely.
+ add_filter( 'wpuf_login_turnstile_verify', [ $this, 'default_turnstile_verify' ], 10, 2 );
+
+ // Default error / message renderers for the login template. Extensions
+ // can `remove_action()` these and replace with their own renderer
+ // (e.g. notice-style banners, theme-styled alerts) without having to
+ // override the template file.
+ add_action( 'wpuf_login_show_errors', [ $this, 'show_errors' ] );
+ add_action( 'wpuf_login_show_messages', [ $this, 'show_messages' ] );
}
/**
@@ -368,6 +380,18 @@ public function login_form() {
$args = [
'action_url' => $login_page,
'redirect_to' => isset( $getdata['redirect_to'] ) ? $getdata['redirect_to'] : '',
+ /**
+ * Filter the CSS class applied to the login form wrapper.
+ *
+ * Allows Pro / extensions to apply their layout class server-side,
+ * eliminating the flash-of-unstyled-content caused by JS-based
+ * class application.
+ *
+ * @since WPUF_SINCE
+ *
+ * @param string $layout_class CSS class for the wrapper. Default empty.
+ */
+ 'layout_class' => apply_filters( 'wpuf_login_form_layout_class', '' ),
];
switch ( $action ) {
@@ -407,9 +431,9 @@ public function login_form() {
default:
$loggedout = isset( $getdata['loggedout'] ) ? sanitize_text_field( $getdata['loggedout'] ) : '';
- $enable_turnstile = wpuf_get_option( 'enable_turnstile', 'wpuf_general', 'off' );
+ $login_form_turnstile = wpuf_get_option( 'login_form_turnstile', 'wpuf_profile', 'off' );
- if ( 'on' === $enable_turnstile ) {
+ if ( 'on' === $login_form_turnstile ) {
wp_enqueue_script( 'wpuf-turnstile' );
}
@@ -419,6 +443,34 @@ public function login_form() {
$args['redirect_to'] = $this->get_login_redirect_link( $args['redirect_to'] );
+ /**
+ * Filter the args passed to the login-form template.
+ *
+ * Lets Pro / extensions inject custom labels, placeholders,
+ * help text, button text, and the form heading server-side
+ * — so screen readers, crawlers, and page caches see the
+ * customized text instead of the English template defaults.
+ *
+ * Recognized keys consumed by `templates/login-form.php`:
+ * - `form_title` (string) heading rendered above the form
+ * - `form_subtitle` (string) subtitle rendered below the heading
+ * - `username_label` (string) label for the username/email input
+ * - `username_placeholder` (string) placeholder for the username input
+ * - `password_label` (string) label for the password input
+ * - `password_placeholder` (string) placeholder for the password input
+ * - `remember_me_text` (string) label for the remember-me checkbox
+ * - `lost_password_text` (string) anchor text for the lost-password link
+ * - `button_text` (string) value for the submit button
+ *
+ * Unknown keys are exposed to overridden templates via
+ * `extract()` and are otherwise ignored.
+ *
+ * @since WPUF_SINCE
+ *
+ * @param array $args Template args (passed to `wpuf_load_template`).
+ */
+ $args = apply_filters( 'wpuf_login_form_template_args', $args );
+
wpuf_load_template( 'login-form.php', $args );
break;
@@ -431,16 +483,55 @@ public function login_form() {
/**
* Verify if cloudflare turnstile request is successful
*
+ * Delegates to the `wpuf_login_turnstile_verify` filter so Pro / extensions
+ * can swap or augment the verification (e.g. alternative captcha provider,
+ * stubbing in tests, additional risk checks). The default callback
+ * `default_turnstile_verify()` retains the original Cloudflare flow.
+ *
* @since 4.0.13
*
* @return bool
*/
private function verify_cloudflare_turnstile_on_login() {
+ /**
+ * Filter the result of the login-form Turnstile verification.
+ *
+ * Return `true` to allow the login, `false` to block. Implementations
+ * that want to surface their own error message should append to
+ * `$this->cf_messages` (via this class) or hook `wpuf_login_errors`.
+ *
+ * @since WPUF_SINCE
+ *
+ * @param bool $is_valid Whether verification passed. Default true.
+ * @param Simple_Login $context This Simple_Login instance, for access to `cf_messages`.
+ */
+ return (bool) apply_filters( 'wpuf_login_turnstile_verify', true, $this );
+ }
+
+ /**
+ * Default Turnstile verification — original Cloudflare HTTP flow.
+ *
+ * Registered as the default `wpuf_login_turnstile_verify` callback in
+ * `__construct()`. Reads `login_form_turnstile` from `wpuf_profile` to
+ * decide whether verification is required, then POSTs the user's token
+ * to Cloudflare's siteverify endpoint.
+ *
+ * Public so the filter can dispatch to it; not part of the documented
+ * extension surface — extensions should hook the filter, not call this
+ * directly.
+ *
+ * @since WPUF_SINCE
+ *
+ * @param bool $is_valid Pre-filter value (ignored; this is the default).
+ * @param Simple_Login $context Owning Simple_Login instance for error-message storage.
+ * @return bool
+ */
+ public function default_turnstile_verify( $is_valid, $context ) {
$nonce = isset( $_POST['wpuf-login-nonce'] ) ? sanitize_key( wp_unslash( $_POST['wpuf-login-nonce'] ) ) : '';
- $enable_turnstile = wpuf_get_option( 'enable_turnstile', 'wpuf_general', 'off' );
+ $login_form_turnstile = wpuf_get_option( 'login_form_turnstile', 'wpuf_profile', 'off' );
- if ( 'on' !== $enable_turnstile ) {
+ if ( 'on' !== $login_form_turnstile ) {
return true;
}
@@ -474,7 +565,7 @@ private function verify_cloudflare_turnstile_on_login() {
if ( ! empty( $body['success'] ) ) {
return true;
} else {
- $this->cf_messages[] = ! empty( $body['error-codes'] ) ? $body['error-codes'] : '';
+ $context->cf_messages[] = ! empty( $body['error-codes'] ) ? $body['error-codes'] : '';
return false;
}
@@ -504,6 +595,17 @@ public function process_login() {
return;
}
+ // 2FA challenge submission. Stage 2 carries only the verification
+ // code + token — credentials are empty (already verified in stage 1)
+ // and Turnstile is not re-rendered on the challenge view. Skip so
+ // we don't stack "Username is required" / "Password is required" /
+ // "Cloudflare Turnstile verification failed" alongside the actual
+ // 2FA error. The 2FA controller owns this request from `init`@9.
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ if ( ! empty( $_POST['wpuf_2fa_token'] ) ) {
+ return;
+ }
+
// unset the specific cookie to fix WooCommerce Stripe Gateway plugin conflict
add_action( 'set_logged_in_cookie', [ $this, 'unset_logged_in_cookie' ], 11 );
@@ -533,7 +635,6 @@ public function process_login() {
'',
$errors
);
- '
' . __( 'Error', 'wp-user-frontend' ) . ': ' . __( 'Cloudflare Turnstile verification failed. Reasons: [', 'wp-user-frontend' );
}
$validation_error = new WP_Error();
diff --git a/includes/TwoFactor/Account_Section.php b/includes/TwoFactor/Account_Section.php
new file mode 100644
index 000000000..3351af251
--- /dev/null
+++ b/includes/TwoFactor/Account_Section.php
@@ -0,0 +1,84 @@
+registry = $registry;
+ $this->storage = $storage;
+
+ add_filter( 'wpuf_account_sections', [ $this, 'register_section' ] );
+ add_action( 'wpuf_account_content_' . self::SECTION_ID, [ $this, 'render_section' ], 10, 2 );
+ }
+
+ public function register_section( $sections ) {
+ if ( ! $this->is_2fa_enabled_globally() ) {
+ return $sections;
+ }
+
+ if ( ! is_array( $sections ) ) {
+ $sections = (array) $sections;
+ }
+
+ $sections[ self::SECTION_ID ] = __( 'Security', 'wp-user-frontend' );
+
+ return $sections;
+ }
+
+ public function render_section( $sections, $current_section ) {
+ if ( ! $this->is_2fa_enabled_globally() ) {
+ return;
+ }
+
+ $user_id = get_current_user_id();
+ $methods = $this->registry->active();
+
+ wp_enqueue_script( 'wpuf-2fa-account' );
+
+ wpuf_load_template(
+ 'dashboard/security.php',
+ [
+ 'sections' => $sections,
+ 'current_section' => $current_section,
+ 'user_id' => $user_id,
+ 'methods' => $methods,
+ 'storage' => $this->storage,
+ 'nonce' => wp_create_nonce( Enrollment_Controller::NONCE_ACTION ),
+ 'ajax_url' => admin_url( 'admin-ajax.php' ),
+ ]
+ );
+ }
+
+ private function is_2fa_enabled_globally(): bool {
+ if ( wpuf_get_option( 'enable_2fa', 'wpuf_2fa', 'off' ) !== 'on' ) {
+ return false;
+ }
+
+ $active = wpuf_get_option( 'active_2fa_methods', 'wpuf_2fa', [] );
+
+ return is_array( $active ) && ! empty( $active );
+ }
+}
diff --git a/includes/TwoFactor/Admin_User_Profile.php b/includes/TwoFactor/Admin_User_Profile.php
new file mode 100644
index 000000000..ff5a3d56f
--- /dev/null
+++ b/includes/TwoFactor/Admin_User_Profile.php
@@ -0,0 +1,219 @@
+registry = $registry;
+ $this->storage = $storage;
+
+ add_action( 'show_user_profile', [ $this, 'render_section' ], 35 );
+ add_action( 'edit_user_profile', [ $this, 'render_section' ], 35 );
+ add_action( 'admin_post_' . self::ACTION, [ $this, 'handle_reset' ] );
+ }
+
+ public function render_section( $user ) {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ $enrolled = $this->registry->enrolled_for( $user->ID );
+ $audit_log = $this->storage->get_audit_log( $user->ID );
+ ?>
+
+
+ registry->enrolled_for( $target_user_id );
+
+ foreach ( $enrolled as $method ) {
+ $method->reset( $target_user_id );
+
+ $this->storage->append_audit(
+ $target_user_id,
+ $method->get_id(),
+ 'admin_reset',
+ $actor_id
+ );
+
+ do_action( 'wpuf_2fa_method_disabled', $target_user_id, $method->get_id(), $actor_id, 'admin_reset' );
+ }
+
+ wp_safe_redirect( add_query_arg( 'wpuf_2fa_reset', '1', get_edit_user_link( $target_user_id ) ) );
+ exit;
+ }
+
+ private function action_label( $action ): string {
+ switch ( $action ) {
+ case 'enrolled':
+ return __( 'Enrolled', 'wp-user-frontend' );
+ case 'self_disable':
+ return __( 'Self-disabled', 'wp-user-frontend' );
+ case 'admin_reset':
+ return __( 'Admin reset', 'wp-user-frontend' );
+ case 'challenge_issued':
+ return __( 'Challenge issued', 'wp-user-frontend' );
+ case 'challenge_issue_failed':
+ return __( 'Challenge issue failed', 'wp-user-frontend' );
+ default:
+ return $action;
+ }
+ }
+}
diff --git a/includes/TwoFactor/Crypto.php b/includes/TwoFactor/Crypto.php
new file mode 100644
index 000000000..2273083dc
--- /dev/null
+++ b/includes/TwoFactor/Crypto.php
@@ -0,0 +1,104 @@
+derive_key(),
+ OPENSSL_RAW_DATA,
+ $iv,
+ $tag
+ );
+
+ if ( false === $ciphertext ) {
+ return '';
+ }
+
+ // base64 here is binary-to-text encoding for storage, not obfuscation.
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+ return self::STORAGE_VERSION
+ . '.' . base64_encode( $iv ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+ . '.' . base64_encode( $tag ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+ . '.' . base64_encode( $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+ }
+
+ /**
+ * Decrypt a value produced by encrypt(). Returns null on failure
+ * (corrupted, key rotated, or version unsupported).
+ *
+ * @param string $payload
+ *
+ * @return string|null
+ */
+ public function decrypt( $payload ) {
+ if ( ! is_string( $payload ) || $payload === '' ) {
+ return null;
+ }
+
+ $parts = explode( '.', $payload );
+
+ if ( count( $parts ) !== 4 || $parts[0] !== self::STORAGE_VERSION ) {
+ return null;
+ }
+
+ // base64 here is binary-to-text decoding for storage, not obfuscation.
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
+ $iv = base64_decode( $parts[1], true );
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
+ $tag = base64_decode( $parts[2], true );
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
+ $ciphertext = base64_decode( $parts[3], true );
+
+ if ( $iv === false || $tag === false || $ciphertext === false ) {
+ return null;
+ }
+
+ $plaintext = openssl_decrypt(
+ $ciphertext,
+ self::CIPHER,
+ $this->derive_key(),
+ OPENSSL_RAW_DATA,
+ $iv,
+ $tag
+ );
+
+ return $plaintext === false ? null : $plaintext;
+ }
+
+ /**
+ * Derive a 32-byte key from AUTH_KEY using HKDF-SHA256.
+ *
+ * Falls back to wp_salt() if AUTH_KEY is not defined (default WP installs
+ * always define it; this guard is for malformed configs).
+ */
+ private function derive_key() {
+ $material = defined( 'AUTH_KEY' ) && AUTH_KEY ? AUTH_KEY : wp_salt( 'auth' );
+
+ return hash_hkdf( 'sha256', $material, 32, 'wpuf-2fa-secret-v1', '' );
+ }
+}
diff --git a/includes/TwoFactor/Default_Card_Renderer.php b/includes/TwoFactor/Default_Card_Renderer.php
new file mode 100644
index 000000000..abe090e06
--- /dev/null
+++ b/includes/TwoFactor/Default_Card_Renderer.php
@@ -0,0 +1,188 @@
+`/`