diff --git a/assets/css/routes/user-center/settings.css b/assets/css/routes/user-center/settings.css index 879d8667..37f9c402 100644 --- a/assets/css/routes/user-center/settings.css +++ b/assets/css/routes/user-center/settings.css @@ -624,6 +624,8 @@ background: var(--main-bg-color, var(--surface-1, #fff)); } +.aksrt-settings-modal [hidden] { display: none !important; } + .aksrt-settings-modal .modal-colorful-header { position: absolute; top: 0; @@ -643,54 +645,50 @@ cursor: pointer; } -.aksrt-settings-modal form { +.aksrt-settings-modal > form { position: relative; z-index: 0; margin-top: 120px; padding: 18px 15px; } -.aksrt-settings-modal__fields { padding: 0 2px; } -.aksrt-settings-modal .modal-field { position: relative; margin-bottom: 14px; } -.aksrt-settings-modal .modal-field label { display: block; margin-bottom: 6px; color: var(--muted-2-color, #777); font-size: 12px; font-weight: 400; } -.aksrt-settings-modal .modal-field input, -.aksrt-settings-modal .readonly-display { - display: block; - width: 100%; - min-height: 38px; - box-sizing: border-box; - padding: 8px 38px 8px 11px; - border: 1px solid var(--main-border-color, var(--color-line, rgba(50, 50, 50, .12))); - border-radius: var(--mini-radius, 4px); - background: var(--main-bg-color, var(--surface-1, #fff)); - color: var(--color-ink, #333); - font-size: 13px; - outline: 0; +.aksrt-settings-modal__pane { + position: relative; + z-index: 0; + margin-top: 120px; + padding: 18px 15px; } -.aksrt-settings-modal .readonly-display { padding-right: 11px; color: var(--muted-2-color, #777); word-break: break-all; } -.aksrt-settings-modal .modal-field input:focus { border-color: var(--focus-color, var(--color-primary, #2997f7)); box-shadow: 0 0 0 2px var(--focus-color-opacity1, rgba(41, 151, 247, .12)); } + +.aksrt-settings-modal__pane form, +.aksrt-settings-modal .account-step form { + margin: 0; + padding: 0; +} + +.aksrt-settings-modal .account-step-list { + position: relative; + z-index: 0; + margin-top: 120px; + padding: 18px 15px 0; +} + +.aksrt-settings-modal .account-step-panels { position: relative; z-index: 0; } +.aksrt-settings-modal .account-step { display: none; padding: 0 15px 18px; } +.aksrt-settings-modal .account-step.is-active { display: block; } +.aksrt-settings-modal .account-step > p { margin: 0 0 12px; } +.aksrt-settings-modal .account-recovery-toggle { border: 0; background: transparent; cursor: pointer; } + +.aksrt-settings-modal__fields { padding: 0 2px; } .aksrt-settings-modal .toggle-pwd { position: absolute; right: 7px; bottom: 4px; width: 30px; height: 30px; padding: 0; border: 0; background: transparent; color: var(--muted-2-color, #999); cursor: pointer; } .aksrt-settings-modal .toggle-pwd:hover { color: var(--color-ink, #333); } -.aksrt-settings-modal .code-input-group { display: flex; align-items: stretch; gap: 7px; } -.aksrt-settings-modal .code-input-group input { min-width: 0; padding-right: 11px; letter-spacing: 2px; } .aksrt-settings-modal .code-send-btn { min-width: 112px; padding: 6px 10px; white-space: nowrap; } .aksrt-settings-modal .code-send-btn.is-counting { pointer-events: none; opacity: .6; } -.aksrt-settings-modal .pwd-strength { height: 3px; margin-top: 6px; border-radius: 2px; background: var(--muted-border-color, #eee); overflow: hidden; } -.aksrt-settings-modal .pwd-strength__fill { width: 0; height: 100%; border-radius: inherit; transition: width .2s ease, background-color .2s ease; } -.aksrt-settings-modal .pwd-strength__fill.level-weak { width: 33%; background: var(--color-error, #ef4444); } -.aksrt-settings-modal .pwd-strength__fill.level-medium { width: 66%; background: var(--color-warning, #f59e0b); } -.aksrt-settings-modal .pwd-strength__fill.level-strong { width: 100%; background: var(--color-success, #10b981); } -.aksrt-settings-modal .pwd-strength__text { display: block; min-height: 16px; margin-top: 3px; color: var(--muted-2-color, #999); font-size: 11px; } -.aksrt-settings-modal .pwd-strength__text.level-weak { color: var(--color-error, #ef4444); } -.aksrt-settings-modal .pwd-strength__text.level-medium { color: var(--color-warning, #f59e0b); } -.aksrt-settings-modal .pwd-strength__text.level-strong { color: var(--color-success, #10b981); } .aksrt-settings-modal .aksrt-captcha-wrap { margin: 12px 0 8px; } @media (max-width: 768px) { .aksrt-modal-runtime.aksrt-user-settings-modal.aksrt-modal-runtime--bottom .aksrt-modal-runtime__dialog { max-height: calc(100dvh - env(safe-area-inset-bottom)); } - .aksrt-settings-modal .code-input-group { gap: 6px; } .aksrt-settings-modal .code-send-btn { min-width: 104px; } } diff --git a/assets/js/routes/user-center-auth.js b/assets/js/routes/user-center-auth.js index 27d7c1d9..bf712644 100644 --- a/assets/js/routes/user-center-auth.js +++ b/assets/js/routes/user-center-auth.js @@ -167,7 +167,7 @@ var submitLabel = button ? button.innerHTML : ''; if (button) { button.disabled = true; - button.innerHTML = '正在提交…'; + button.innerHTML = '正在提交…'; } toast('正在提交申请…', 'load', '', 'aksrt_identity_submit'); window.AksrtRequest.request(config.restBase, { diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index 2c1e8ab3..13a69a9f 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -349,7 +349,7 @@ } /* ==================== 个人信息保存 ==================== */ - + function initProfileForm(root) { root = root || document; @@ -718,7 +718,6 @@ var runtime = window.AksrtModalRuntime; var template = document.querySelector('template[data-aksrt-settings-modal-template="' + kind + '"]'); if (!runtime || typeof runtime.open !== 'function' || !template) return; - var title = kind === 'password' ? '修改密码' : (kind === 'email' ? '更改邮箱' : '绑定手机'); runtime.open({ html: template.innerHTML, kind: 'account-settings', @@ -727,252 +726,349 @@ onContent: function (_, session) { var shell = session && session.shell; if (!shell) return; - shell.setAttribute('aria-label', title); + var dialog = shell.querySelector('[data-account-dialog]'); + var title = dialog && dialog.querySelector('[data-account-password-title], .modal-colorful-header .text-center > div:last-child'); + if (title && title.textContent.trim()) shell.setAttribute('aria-label', title.textContent.trim()); var firstInput = shell.querySelector('input:not([type="hidden"]), textarea, select'); if (firstInput) firstInput.focus(); - bindModalFields(shell); - initBindPhone(shell); + bindDialog(shell); }, }); } - function bindModalFields(scope) { - if (!scope) return; - scope.querySelectorAll('.toggle-pwd').forEach(function(btn) { - if (btn.dataset.aksrtSettingsInitialized === 'true') return; - btn.dataset.aksrtSettingsInitialized = 'true'; - btn.addEventListener('click', function() { - var inp = scope.querySelector('#' + this.getAttribute('data-target')); - if (!inp) return; - inp.type = inp.type === 'password' ? 'text' : 'password'; - this.classList.toggle('is-visible'); - var icon = this.querySelector('i'); - if (icon) { - icon.classList.toggle('fa-eye', inp.type === 'password'); - icon.classList.toggle('fa-eye-slash', inp.type === 'text'); - } + function setStep(dialog, key) { + dialog.querySelectorAll('[data-account-step]').forEach(function (panel) { + panel.classList.toggle('is-active', panel.getAttribute('data-account-step') === key); + }); + var panels = Array.prototype.slice.call(dialog.querySelectorAll('[data-account-step]')); + var index = Math.max(0, panels.findIndex(function (panel) { return panel.getAttribute('data-account-step') === key; })); + dialog.querySelectorAll('.account-step-list li').forEach(function (item, itemIndex) { + item.classList.toggle('active', itemIndex === index); + item.classList.toggle('done', itemIndex < index); + }); + } + + function resetCountdown(button) { + if (!button) return; + if (button.__aksrtCountdownTimer) window.clearInterval(button.__aksrtCountdownTimer); + button.__aksrtCountdownTimer = null; + button.disabled = false; + button.classList.remove('is-counting'); + button.textContent = button.getAttribute('data-idle-label') || '发送验证码'; + } + + function startCountdown(button, seconds) { + var idleText = button.textContent; + var left = Number(seconds || 60); + if (button.__aksrtCountdownTimer) window.clearInterval(button.__aksrtCountdownTimer); + button.disabled = true; + button.classList.add('is-counting'); + button.setAttribute('data-idle-label', idleText); + button.textContent = left + 's'; + button.__aksrtCountdownTimer = window.setInterval(function () { + left -= 1; + if (left <= 0) { resetCountdown(button); return; } + button.textContent = left + 's'; + }, 1000); + } + + function settingsRestRoot() { + return String((window.aksrtData && (window.aksrtData.restUrl || window.aksrtData.apiUrl)) || cfg.restUrl || '/wp-json/aksrt/v1').replace(/\/$/, ''); + } + + function settingsRestNonce() { + return (window.aksrtData && (window.aksrtData.restNonce || window.aksrtData.nonce)) || cfg.restNonce || ''; + } + + function formHasRequiredValues(form) { + var required = form.querySelectorAll('[required]'); + for (var i = 0; i < required.length; i++) { + if (required[i].type === 'checkbox' && !required[i].checked) { + required[i].focus(); + notyf(i18n.agreeTerms || '请先同意相关协议', 'warning'); + return false; + } + if (required[i].type !== 'checkbox' && !String(required[i].value || '').trim()) { + required[i].focus(); + notyf(i18n.fillRequired || '请填写必填项', 'warning'); + return false; + } + } + return true; + } + + function sendEmailCode(button, form, scene) { + if (!form || button.disabled) return; + if (scene === 'change_email' && !String((form.querySelector('[name="new_email"]') || {}).value || '').trim()) { + notyf('请输入邮箱', 'warning'); + return; + } + runCaptcha('change_email', form).then(function (captchaPayload) { + if (captchaPayload === null) return null; + var data = new URLSearchParams(); + data.set('action', 'aksrt_send_account_email_code'); + data.set('_settings_nonce', settingsNonce); + data.set('scene', scene); + new FormData(form).forEach(function (value, key) { if (value !== '' && !data.has(key)) data.set(key, value); }); + Object.keys(captchaPayload || {}).forEach(function (key) { data.set(key, captchaPayload[key]); }); + button.disabled = true; + return fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: data.toString() }); + }).then(function (response) { return response ? response.json() : null; }).then(function (payload) { + if (!payload) return; + if (!payload.success) throw new Error((payload.data && payload.data.message) || '发送失败'); + notyf((payload.data && payload.data.message) || '验证码已发送', 'success'); + startCountdown(button, payload.data && payload.data.countdown); + }).catch(function (error) { + resetCountdown(button); + notyf(error.message || '发送失败', 'danger'); + }).finally(function () { resetCaptcha('change_email', form); }); + } + + function sendPhoneCode(button, form, stage) { + if (!form || button.disabled) return; + var phone = String((form.querySelector('[name="phone"]') || {}).value || '').trim(); + if (!phone) { notyf('请输入手机号', 'warning'); return; } + runCaptcha('bind_phone', form).then(function (captchaPayload) { + if (captchaPayload === null) return null; + var restRoot = settingsRestRoot(); + var nonce = settingsRestNonce(); + var request = fetch(restRoot + '/user/phone-code', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, + body: JSON.stringify(Object.assign({ phone: phone, stage: stage }, captchaPayload || {})) + }); + button.disabled = true; + return request; + }).then(function (response) { return response ? response.json().then(function (payload) { return { ok: response.ok, payload: payload }; }) : null; }).then(function (result) { + if (!result) return; + if (!result.ok || !result.payload.success) throw new Error(result.payload.message || '发送失败'); + notyf(result.payload.message || '验证码已发送', 'success'); + startCountdown(button, result.payload.data && result.payload.data.countdown); + }).catch(function (error) { + resetCountdown(button); + notyf(error.message || '发送失败', 'danger'); + }).finally(function () { resetCaptcha('bind_phone', form); }); + } + + function postAjaxForm(form, action, scene, success) { + if (!formHasRequiredValues(form)) return; + var button = form.querySelector('[type="submit"]'); + var label = button && button.querySelector('[data-aksrt-settings-submit-label]'); + var original = label ? label.textContent : ''; + if (button) { button.disabled = true; setSettingsSubmitLabel(button, i18n.submitting || '提交中...'); } + runCaptcha(scene, form).then(function (captchaPayload) { + if (captchaPayload === null) { + if (button) { button.disabled = false; setSettingsSubmitLabel(button, original); } + return null; + } + var data = new FormData(form); + data.set('action', action); + data.set('_settings_nonce', settingsNonce); + Object.keys(captchaPayload || {}).forEach(function (key) { data.set(key, captchaPayload[key]); }); + return fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data }); + }).then(function (response) { return response ? response.json() : null; }).then(function (payload) { + if (!payload) return; + if (button) { button.disabled = false; setSettingsSubmitLabel(button, original); } + if (!payload.success) throw new Error((payload.data && payload.data.message) || '操作失败'); + notyf((payload.data && payload.data.message) || '操作成功', 'success'); + if (typeof success === 'function') success(payload); + }).catch(function (error) { + if (button) { button.disabled = false; setSettingsSubmitLabel(button, original); } + notyf(error.message || '操作失败', 'danger'); + }).finally(function () { resetCaptcha(scene, form); }); + } + + function postPhoneForm(form, endpoint, success) { + if (!formHasRequiredValues(form)) return; + var button = form.querySelector('[type="submit"]'); + var label = button && button.querySelector('[data-aksrt-settings-submit-label]'); + var original = label ? label.textContent : ''; + runCaptcha('bind_phone', form).then(function (captchaPayload) { + if (captchaPayload === null) return null; + var restRoot = settingsRestRoot(); + var nonce = settingsRestNonce(); + if (button) { button.disabled = true; setSettingsSubmitLabel(button, i18n.submitting || '提交中...'); } + var payload = {}; + new FormData(form).forEach(function (value, key) { payload[key] = value; }); + Object.keys(captchaPayload || {}).forEach(function (key) { payload[key] = captchaPayload[key]; }); + return fetch(restRoot + endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, body: JSON.stringify(payload) }); + }).then(function (response) { return response ? response.json().then(function (payload) { return { ok: response.ok, payload: payload }; }) : null; }).then(function (result) { + if (!result) return; + if (!result.ok || !result.payload.success) throw new Error(result.payload.message || '操作失败'); + if (button) { button.disabled = false; setSettingsSubmitLabel(button, original); } + notyf(result.payload.message || '操作成功', 'success'); + if (typeof success === 'function') success(result.payload); + }).catch(function (error) { + if (button) { button.disabled = false; setSettingsSubmitLabel(button, original); } + notyf(error.message || '操作失败', 'danger'); + }).finally(function () { resetCaptcha('bind_phone', form); }); + } + + function sendRecoveryCode(button, form) { + if (!form || button.disabled) return; + var target = String((form.querySelector('[name="target"]') || {}).value || '').trim(); + if (!target) { notyf('请输入邮箱或手机号', 'warning'); return; } + var dialog = form.closest('[data-account-dialog="password"]'); + button.disabled = true; + runCaptcha('lostpassword', form).then(function (captchaPayload) { + if (captchaPayload === null) { notyf('请先完成人机验证', 'warning'); resetCountdown(button); return null; } + var payload = { scene: 'resetpassword', target: target, channel: dialog ? (dialog.getAttribute('data-recovery-channel') || '') : '' }; + Object.keys(captchaPayload || {}).forEach(function (key) { payload[key] = captchaPayload[key]; }); + return fetch(settingsRestRoot() + '/auth/code', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': settingsRestNonce() }, + body: JSON.stringify(payload) + }); + }).then(function (response) { return response ? response.json().then(function (payload) { return { ok: response.ok, payload: payload }; }) : null; }).then(function (result) { + if (!result) return; + if (!result.ok || !result.payload.success) throw new Error(result.payload.message || '发送失败'); + notyf((result.payload.data && result.payload.data.message) || result.payload.message || '验证码已发送', 'success'); + startCountdown(button, (result.payload.data && result.payload.data.countdown) || result.payload.countdown); + }).catch(function (error) { + resetCountdown(button); + notyf(error.message || '发送失败', 'danger'); + }).finally(function () { resetCaptcha('lostpassword', form); }); + } + + function postRecoveryForm(form) { + if (!formHasRequiredValues(form)) return; + var password = form.querySelector('[name="password"]'); + var confirm = form.querySelector('[name="password_confirm"]'); + if (password && confirm && password.value !== confirm.value) { notyf('两次密码输入不一致', 'warning'); confirm.focus(); return; } + var button = form.querySelector('[type="submit"]'); + var label = button && button.querySelector('[data-aksrt-settings-submit-label]'); + var original = label ? label.textContent : ''; + var dialog = form.closest('[data-account-dialog="password"]'); + if (button) { button.disabled = true; setSettingsSubmitLabel(button, i18n.submitting || '提交中...'); } + runCaptcha('lostpassword', form).then(function (captchaPayload) { + if (captchaPayload === null) { + if (button) { button.disabled = false; setSettingsSubmitLabel(button, original); } + return null; + } + var payload = {}; + new FormData(form).forEach(function (value, key) { payload[key] = value; }); + payload.channel = dialog ? (dialog.getAttribute('data-recovery-channel') || '') : ''; + Object.keys(captchaPayload || {}).forEach(function (key) { payload[key] = captchaPayload[key]; }); + return fetch(settingsRestRoot() + '/auth/reset-password', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': settingsRestNonce() }, + body: JSON.stringify(payload) + }); + }).then(function (response) { return response ? response.json().then(function (payload) { return { ok: response.ok, payload: payload }; }) : null; }).then(function (result) { + if (!result) return; + if (!result.ok || !result.payload.success) throw new Error(result.payload.message || '重设密码失败'); + if (button) { button.disabled = false; setSettingsSubmitLabel(button, original); } + notyf(result.payload.message || (result.payload.data && result.payload.data.message) || '密码已重置', 'success'); + window.setTimeout(function () { + var redirect = result.payload.redirect || (result.payload.data && result.payload.data.redirect); + if (redirect) window.location.assign(redirect); + else window.location.reload(); + }, 700); + }).catch(function (error) { + if (button) { button.disabled = false; setSettingsSubmitLabel(button, original); } + notyf(error.message || '重设密码失败', 'danger'); + }).finally(function () { resetCaptcha('lostpassword', form); }); + } + + function bindDialog(shell) { + var dialog = shell.querySelector('[data-account-dialog]'); + if (!dialog || dialog.dataset.aksrtBound === 'true') return; + dialog.dataset.aksrtBound = 'true'; + dialog.querySelectorAll('.toggle-pwd').forEach(function (button) { + button.addEventListener('click', function () { + var input = dialog.querySelector('#' + button.getAttribute('data-target')); + if (!input) return; + input.type = input.type === 'password' ? 'text' : 'password'; + var icon = button.querySelector('i'); + if (icon) { icon.classList.toggle('fa-eye', input.type === 'password'); icon.classList.toggle('fa-eye-slash', input.type === 'text'); } }); }); - - var pInput = scope.querySelector('#pwd-new'); - var sBar = scope.querySelector('#pwd-strength-bar'); - var sTxt = scope.querySelector('#pwd-strength-text'); - if (pInput && sBar && sTxt) { - pInput.addEventListener('input', function() { - var v = this.value; - var fill = sBar.querySelector('.pwd-strength__fill'); - if (!v) { fill.className = 'pwd-strength__fill'; sTxt.className = 'pwd-strength__text'; sTxt.textContent = ''; return; } - var score = 0; - if (v.length >= 6) score++; - if (v.length >= 10) score++; - if (/[a-z]/.test(v) && /[A-Z]/.test(v)) score++; - if (/\d/.test(v)) score++; - if (/[^a-zA-Z0-9]/.test(v)) score++; - var level = score <= 1 ? 'weak' : (score <= 3 ? 'medium' : 'strong'); - var label = score <= 1 ? '弱' : (score <= 3 ? '中' : '强'); - fill.className = 'pwd-strength__fill level-' + level; - sTxt.className = 'pwd-strength__text level-' + level; - sTxt.textContent = '密码强度: ' + label; + var type = dialog.getAttribute('data-account-dialog'); + if (type === 'password') { + var recoveryTarget = dialog.querySelector('#pwd-recovery-target'); + if (recoveryTarget && dialog.dataset.recoveryTarget) recoveryTarget.value = dialog.dataset.recoveryTarget; + dialog.querySelectorAll('[data-account-recovery-open],[data-account-recovery-back]').forEach(function (button) { + button.addEventListener('click', function () { + var showRecovery = button.hasAttribute('data-account-recovery-open'); + dialog.querySelectorAll('[data-account-password-pane]').forEach(function (pane) { + pane.hidden = pane.getAttribute('data-account-password-pane') === 'recovery' ? !showRecovery : showRecovery; + }); + var title = dialog.querySelector('[data-account-password-title]'); + if (title) { + title.textContent = title.getAttribute(showRecovery ? 'data-recovery-label' : 'data-change-label') || title.textContent; + var shell = dialog.closest('.aksrt-user-settings-modal'); + if (shell) shell.setAttribute('aria-label', title.textContent); + } + var input = dialog.querySelector(showRecovery ? '#pwd-recovery-target' : '#pwd-old'); + if (input) input.focus(); + }); + }); + var passwordForm = dialog.querySelector('#pwd-form'); + if (passwordForm) passwordForm.addEventListener('submit', function (event) { + event.preventDefault(); + var password = passwordForm.querySelector('[name="password"]'); + var confirm = passwordForm.querySelector('[name="password2"]'); + if (password && confirm && password.value !== confirm.value) { notyf('两次密码输入不一致', 'warning'); confirm.focus(); return; } + postAjaxForm(passwordForm, 'aksrt_change_password', 'change_password', function () { window.setTimeout(function () { window.location.reload(); }, 700); }); + }); + var recoveryForm = dialog.querySelector('#pwd-recovery-form'); + var recoveryCodeButton = dialog.querySelector('[data-account-recovery-send]'); + if (recoveryCodeButton && recoveryForm) recoveryCodeButton.addEventListener('click', function () { sendRecoveryCode(recoveryCodeButton, recoveryForm); }); + if (recoveryForm) recoveryForm.addEventListener('submit', function (event) { event.preventDefault(); postRecoveryForm(recoveryForm); }); + } else if (type === 'email') { + dialog.querySelectorAll('.account-code-send').forEach(function (button) { + button.addEventListener('click', function () { sendEmailCode(button, button.closest('form'), button.getAttribute('data-account-code-scene')); }); + }); + var oldForm = dialog.querySelector('#email-old-verify-form'); + var newForm = dialog.querySelector('#email-new-form'); + if (oldForm) oldForm.addEventListener('submit', function (event) { + event.preventDefault(); + postAjaxForm(oldForm, 'aksrt_verify_account_email', 'change_email', function () { setStep(dialog, 'email-new'); }); + }); + if (newForm) newForm.addEventListener('submit', function (event) { + event.preventDefault(); + postAjaxForm(newForm, 'aksrt_change_email', 'change_email', function () { + if (dialog.querySelector('[data-account-step="email-success"]')) setStep(dialog, 'email-success'); + window.setTimeout(function () { window.location.reload(); }, 700); + }); + }); + } else if (type === 'phone') { + dialog.querySelectorAll('[data-aksrt-sms-trigger]').forEach(function (button) { + button.addEventListener('click', function () { sendPhoneCode(button, button.closest('form'), button.getAttribute('data-aksrt-sms-scene')); }); + }); + var phoneOldForm = dialog.querySelector('#phone-old-verify-form'); + var phoneNewForm = dialog.querySelector('#aksrt-bind-phone-form'); + if (phoneOldForm) phoneOldForm.addEventListener('submit', function (event) { + event.preventDefault(); + postPhoneForm(phoneOldForm, '/user/phone-verify', function () { setStep(dialog, 'phone-new'); }); + }); + if (phoneNewForm) phoneNewForm.addEventListener('submit', function (event) { + event.preventDefault(); + postPhoneForm(phoneNewForm, '/user/phone', function () { + if (dialog.querySelector('[data-account-step="phone-success"]')) setStep(dialog, 'phone-success'); + window.setTimeout(function () { window.location.reload(); }, 700); + }); }); } - bindSend('pwd-send-code', 'pwd-form', 'change_password', scope); - bindSend('email-send-code', 'email-form', 'change_email', scope); - submitForm('pwd-form', 'aksrt_change_password', scope); - submitForm('email-form', 'aksrt_change_email', scope); } function resetSettingsModal(shell) { if (!shell || !shell.classList.contains('aksrt-user-settings-modal')) return; - shell.dataset.aksrtSettingsModalClosed = 'true'; - var forms = shell.querySelectorAll('form'); - var sceneMap = { 'pwd-form': 'change_password', 'email-form': 'change_email', 'aksrt-bind-phone-form': 'bind_phone' }; - forms.forEach(function(form) { - form.reset(); - var scene = sceneMap[form.id]; - if (scene) resetCaptcha(scene, form); - form.querySelectorAll('[data-aksrt-countdown]').forEach(function(button) { - if (button.__aksrtCountdownTimer) window.clearInterval(button.__aksrtCountdownTimer); - button.__aksrtCountdownTimer = null; - button.disabled = false; - button.classList.remove('is-counting'); - button.textContent = button.getAttribute('data-idle-label') || button.textContent; - }); - }); - var bar = shell.querySelector('#pwd-strength-bar'); - var fill = bar && bar.querySelector('.pwd-strength__fill'); - var text = shell.querySelector('#pwd-strength-text'); - if (fill) fill.className = 'pwd-strength__fill'; - if (text) { text.textContent = ''; text.className = 'pwd-strength__text'; } + shell.querySelectorAll('.account-code-send,[data-aksrt-sms-trigger],[data-account-recovery-send]').forEach(resetCountdown); + shell.querySelectorAll('form').forEach(function (form) { form.reset(); }); } if (document.documentElement.dataset.aksrtSettingsModalCleanupInitialized !== 'true') { document.documentElement.dataset.aksrtSettingsModalCleanupInitialized = 'true'; - document.addEventListener('aksrt:modal:close', function(event) { - var shell = event.detail && event.detail.shell; - resetSettingsModal(shell); - }); + document.addEventListener('aksrt:modal:close', function (event) { resetSettingsModal(event.detail && event.detail.shell); }); } - root.querySelectorAll('[data-aksrt-settings-modal]').forEach(function(btn) { - if (btn.dataset.aksrtSettingsInitialized === 'true') return; - btn.dataset.aksrtSettingsInitialized = 'true'; - btn.addEventListener('click', function() { - openSettingsModal(this.getAttribute('data-aksrt-settings-modal')); - }); + root.querySelectorAll('[data-aksrt-settings-modal]').forEach(function (button) { + if (button.dataset.aksrtSettingsInitialized === 'true') return; + button.dataset.aksrtSettingsInitialized = 'true'; + button.addEventListener('click', function () { openSettingsModal(button.getAttribute('data-aksrt-settings-modal')); }); }); - - // 验证码发送 - function getSendBtnIdleText(btnId) { - if (btnId === 'pwd-send-code') return i18n.sendToCurrentEmail || '发送到当前邮箱'; - if (btnId === 'email-send-code') return i18n.sendToNewEmail || '发送到新邮箱'; - return i18n.sendCode || '发送验证码'; - } - - function bindSend(btnId, formId, scene, scope) { - var container = scope || document; - var b = container.querySelector ? container.querySelector('#' + btnId) : document.getElementById(btnId); - if (!b) return; - if (b.dataset.aksrtSettingsInitialized === 'true') return; - b.dataset.aksrtSettingsInitialized = 'true'; - b.addEventListener('click', function() { - if (this.classList.contains('is-counting')) return; - var self = this; - var form = container.querySelector ? container.querySelector('#' + formId) : document.getElementById(formId); - var toastId = scene === 'change_password' - ? 'aksrt-settings-password-code-toast' - : 'aksrt-settings-email-code-toast'; - runCaptcha(scene, form).then(function(captchaPayload) { - if (captchaPayload === null) return; - if (container.dataset && container.dataset.aksrtSettingsModalClosed === 'true') return; - self.disabled = true; - self.textContent = i18n.sending || '发送中...'; - notyf(i18n.sending || '正在发送验证码…', 'load', '', toastId); - var data = new URLSearchParams(); - data.set('action', 'aksrt_send_email_code'); - data.set('_settings_nonce', settingsNonce); - data.set('scene', scene || ''); - if (form) { - new FormData(form).forEach(function(value, key) { - if (data.has(key) || value === '' || value == null) return; - data.set(key, value); - }); - } - Object.keys(captchaPayload || {}).forEach(function(key) { data.set(key, captchaPayload[key]); }); - fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: data.toString() }) - .then(function(r) { return r.json(); }).then(function(res) { - if (res.success) { - notyf((res.data && res.data.message) || i18n.codeSent || '验证码已发送', 'success', 5, toastId); - if (container.dataset && container.dataset.aksrtSettingsModalClosed === 'true') return; - var sec = 60; - self.classList.add('is-counting'); - self.textContent = sec + 's 后重发'; - self.setAttribute('data-aksrt-countdown', ''); - self.setAttribute('data-idle-label', getSendBtnIdleText(btnId)); - var t = setInterval(function() { - sec--; - if (sec <= 0) { - clearInterval(t); - self.__aksrtCountdownTimer = null; - self.disabled = false; - self.classList.remove('is-counting'); - self.textContent = getSendBtnIdleText(btnId); - } else { - self.textContent = sec + 's 后重发'; - } - }, 1000); - self.__aksrtCountdownTimer = t; - } else { - notyf((res.data && res.data.message) || i18n.sendFailed || '发送失败', 'danger', 5, toastId); - self.disabled = false; - self.textContent = getSendBtnIdleText(btnId); - } - }) - .catch(function() { - notyf(i18n.networkError || '网络错误', 'danger', 5, toastId); - self.disabled = false; - self.textContent = getSendBtnIdleText(btnId); - }) - .finally(function() { - resetCaptcha(scene, form); - }); - }).catch(function() { - resetCaptcha(scene, form); - notyf('人机验证失败,请重试', 'danger', 5, toastId); - }); - }); - } - - // 表单提交 - function submitForm(fid, act, scope) { - var container = scope || document; - var fm = container.querySelector ? container.querySelector('#' + fid) : document.getElementById(fid); - if (!fm) return; - if (fm.dataset.aksrtSettingsInitialized === 'true') return; - fm.dataset.aksrtSettingsInitialized = 'true'; - fm.addEventListener('submit', function(e) { - e.preventDefault(); - var btn = fm.querySelector('[type=submit]'); - var submitLabel = btn && btn.querySelector('[data-aksrt-settings-submit-label]'); - var orig = submitLabel ? submitLabel.textContent : ''; - var toastId = fid === 'pwd-form' - ? 'aksrt-settings-password-toast' - : 'aksrt-settings-email-toast'; - var reqs = fm.querySelectorAll('[required]'); - for (var i = 0; i < reqs.length; i++) { - if (!reqs[i].value.trim()) { reqs[i].focus(); notyf(i18n.fillRequired || '请填写必填项', 'warning'); return; } - } - if (fid === 'pwd-form') { - var pwdNew = fm.querySelector('#pwd-new'); - var pwdConfirm = fm.querySelector('#pwd-new-confirm'); - if (pwdNew && pwdConfirm && pwdNew.value !== pwdConfirm.value) { - pwdConfirm.focus(); - notyf(i18n.passwordMismatch || '两次输入的新密码不一致', 'warning'); - return; - } - } - if (btn) { btn.disabled = true; setSettingsSubmitLabel(btn, i18n.submitting || '提交中...'); } - notyf(i18n.submitting || '正在提交…', 'load', '', toastId); - var scene = fid === 'pwd-form' ? 'change_password' : 'change_email'; - runCaptcha(scene, fm).then(function(captchaPayload) { - if (captchaPayload === null) { - if (btn) { btn.disabled = false; setSettingsSubmitLabel(btn, orig); } - return null; - } - if (container.dataset && container.dataset.aksrtSettingsModalClosed === 'true') return null; - var modalShell = fm.closest('.aksrt-user-settings-modal'); - if (modalShell && modalShell.dataset.aksrtSettingsModalClosed === 'true') return null; - var d = new FormData(fm); - d.set('action', act); - d.set('_settings_nonce', settingsNonce); - Object.keys(captchaPayload || {}).forEach(function(key) { d.set(key, captchaPayload[key]); }); - return fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', body: d }); - }) - .then(function(r) { return r ? r.json() : null; }) - .then(function(r) { - if (!r) return; - if (btn) { btn.disabled = false; setSettingsSubmitLabel(btn, orig); } - if (r.success) { - notyf((r.data && r.data.message) || '操作成功', 'success', 5, toastId); - var activeModal = window.AksrtModalRuntime && window.AksrtModalRuntime.getActive(); - if (activeModal && activeModal.classList.contains('aksrt-user-settings-modal') && activeModal.contains(fm)) { - window.AksrtModalRuntime.close('settings-submit'); - } - if (r.data && r.data.redirect_to) setTimeout(function() { window.location.href = r.data.redirect_to; }, 2500); - } else { - notyf((r.data && r.data.message) || i18n.operationFailed || '操作失败', 'danger', 5, toastId); - } - }) - .catch(function() { - if (btn) { btn.disabled = false; setSettingsSubmitLabel(btn, orig); } - notyf(i18n.networkError || '网络错误', 'danger', 5, toastId); - }) - .finally(function() { - resetCaptcha(scene, fm); - }); - }); - } - - root.querySelectorAll('.binding-item--linkable').forEach(function(link) { + root.querySelectorAll('.binding-item--linkable').forEach(function(link) { if (link.dataset.aksrtBindCaptchaInitialized === 'true') return; link.dataset.aksrtBindCaptchaInitialized = 'true'; link.addEventListener('click', function(event) { @@ -1134,145 +1230,9 @@ initNotifications(); initNotificationPreferences(); initSettings(); - initBindPhone(); initSettingsCallbackToast(); }); - // 绑定手机 - function initBindPhone(root) { - root = root || document; - var form = root.id === 'aksrt-bind-phone-form' ? root : root.querySelector('#aksrt-bind-phone-form'); - if (!form) return; - if (form.dataset.aksrtSettingsInitialized === 'true') return; - form.dataset.aksrtSettingsInitialized = 'true'; - function isValidSmsPhone(phone) { - if (!window.aksrtData || aksrtData.smsNumberLimitCh !== false) { - return /^1[3-9]\d{9}$/.test(phone); - } - return /^(?:\d{5,}|\+\d{6,}|\+?\d{2,}[-\s]\d{2,}[0-9\-\s]{0,15})$/.test(phone); - } - var sendButton = form.querySelector('[data-aksrt-sms-trigger]'); - if (sendButton) sendButton.addEventListener('click', function () { - var phone = String((form.querySelector('[name="phone"]') || {}).value || '').trim(); - if (!isValidSmsPhone(phone)) { notyf('请输入正确的手机号', 'warning'); return; } - var toastId = 'aksrt-settings-phone-code-toast'; - var restRoot = (window.aksrtData && aksrtData.apiUrl) || '/wp-json/aksrt/v1'; - var nonce = (window.aksrtData && aksrtData.restNonce) || ''; - sendButton.disabled = true; - notyf('正在发送验证码…', 'load', '', toastId); - runCaptcha('bind_phone', form).then(function(captchaPayload) { - if (captchaPayload === null) { - sendButton.disabled = false; - return null; - } - var modalShell = form.closest('.aksrt-user-settings-modal'); - if (modalShell && modalShell.dataset.aksrtSettingsModalClosed === 'true') return null; - return fetch(String(restRoot).replace(/\/$/, '') + '/user/phone-code', { - method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, - body: JSON.stringify(Object.assign({ phone: phone }, captchaPayload || {})) - }); - }).then(function (r) { return r ? r.json().then(function (j) { return { ok: r.ok, json: j }; }) : null; }).then(function (res) { - if (!res) return; - if (!res.ok || !res.json.success) throw new Error(res.json.message || '发送失败'); - notyf(res.json.message || '验证码已发送', 'success', 5, toastId); - var modalShell = form.closest('.aksrt-user-settings-modal'); - if (modalShell && modalShell.dataset.aksrtSettingsModalClosed === 'true') return; - var left = Number((res.json.data && res.json.data.countdown) || 60); - sendButton.setAttribute('data-aksrt-countdown', ''); - sendButton.setAttribute('data-idle-label', '获取验证码'); - sendButton.classList.add('is-counting'); - var timer = setInterval(function () { - left -= 1; - sendButton.textContent = left + 's'; - if (left <= 0) { - clearInterval(timer); - sendButton.__aksrtCountdownTimer = null; - sendButton.disabled = false; - sendButton.classList.remove('is-counting'); - sendButton.textContent = sendButton.getAttribute('data-idle-label'); - } - }, 1000); - sendButton.__aksrtCountdownTimer = timer; - }).catch(function (error) { sendButton.disabled = false; notyf(error.message || '发送失败', 'danger', 5, toastId); }) - .finally(function () { resetCaptcha('bind_phone', form); }); - }); - form.addEventListener('submit', function (e) { - e.preventDefault(); - var phoneEl = form.querySelector('[name="phone"]'); - var codeEl = form.querySelector('[name="code"]'); - var phone = phoneEl ? String(phoneEl.value || '').trim() : ''; - var code = codeEl ? String(codeEl.value || '').trim() : ''; - if (!isValidSmsPhone(phone)) { - notyf('请输入正确的手机号', 'warning'); - return; - } - if (!/^\d{6}$/.test(code)) { - notyf('请输入短信验证码', 'warning'); - return; - } - var submitBtn = form.querySelector('[type="submit"]'); - var submitLabel = submitBtn && submitBtn.querySelector('[data-aksrt-settings-submit-label]'); - var submitIdleText = submitLabel ? submitLabel.textContent : ''; - if (submitBtn) { - submitBtn.disabled = true; - setSettingsSubmitLabel(submitBtn, '绑定中...'); - } - function doBind(captcha) { - var toastId = 'aksrt-settings-phone-bind-toast'; - notyf('正在绑定手机…', 'load', '', toastId); - var restRoot = (window.aksrtData && aksrtData.apiUrl) || '/wp-json/aksrt/v1'; - var nonce = (window.aksrtData && aksrtData.restNonce) || ''; - return fetch(String(restRoot).replace(/\/$/, '') + '/user/phone', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': nonce - }, - body: JSON.stringify(Object.assign({ phone: phone, code: code }, captcha)) - }) - .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); }) - .then(function (res) { - if (res.ok && res.json && res.json.success) { - notyf(res.json.message || '绑定成功', 'success', 5, toastId); - var activeModal = window.AksrtModalRuntime && window.AksrtModalRuntime.getActive(); - if (activeModal && activeModal.classList.contains('aksrt-user-settings-modal') && activeModal.contains(form)) { - window.AksrtModalRuntime.close('settings-submit'); - } - setTimeout(function () { window.location.reload(); }, 600); - return; - } - notyf((res.json && (res.json.message || res.json.error)) || '绑定失败', 'danger', 5, toastId); - if (submitBtn) { - submitBtn.disabled = false; - setSettingsSubmitLabel(submitBtn, submitIdleText); - } - }) - .catch(function () { - notyf('网络错误', 'danger', 5, toastId); - if (submitBtn) { - submitBtn.disabled = false; - setSettingsSubmitLabel(submitBtn, submitIdleText); - } - }); - } - runCaptcha('bind_phone', form).then(function(captchaPayload) { - if (captchaPayload === null) { - if (submitBtn) { - submitBtn.disabled = false; - setSettingsSubmitLabel(submitBtn, submitIdleText); - } - return null; - } - var modalShell = form.closest('.aksrt-user-settings-modal'); - if (modalShell && modalShell.dataset.aksrtSettingsModalClosed === 'true') return null; - return doBind(captchaPayload); - }).finally(function() { - resetCaptcha('bind_phone', form); - }); - }); - } })(); diff --git a/inc/helpers/user-center-renderers/profile-auth.php b/inc/helpers/user-center-renderers/profile-auth.php index 44188e3a..129ebfbd 100644 --- a/inc/helpers/user-center-renderers/profile-auth.php +++ b/inc/helpers/user-center-renderers/profile-auth.php @@ -81,7 +81,7 @@ $config = [ diff --git a/inc/helpers/user-center-renderers/settings-account.php b/inc/helpers/user-center-renderers/settings-account.php index 1bf58e22..2be64f02 100644 --- a/inc/helpers/user-center-renderers/settings-account.php +++ b/inc/helpers/user-center-renderers/settings-account.php @@ -1,25 +1,29 @@ ID : (int) ($user_id ?? 0); $account_email = $account_user instanceof WP_User ? (string) $account_user->user_email : ''; $account_phone = $account_user_id > 0 ? (string) get_user_meta($account_user_id, 'phone_number', true) : ''; -$user_settings = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::get_all() : []; -$sms_settings = class_exists('\\AKSRT\\Sms\\SMS') ? \AKSRT\Sms\SMS::get_instance()->get_provider_config() : []; +$account_oauth_new = $account_user_id > 0 && get_user_meta($account_user_id, 'oauth_new', true); +$user_settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; +$sms_settings = class_exists('\AKSRT\Sms\SMS') ? \AKSRT\Sms\SMS::get_instance()->get_provider_config() : []; $phone_available = !empty($user_settings['bind_phone']) && (($sms_settings['sms_sdk'] ?? 'null') !== 'null'); -$phone_visible = $account_phone !== '' || $phone_available; -$phone_label = $account_phone !== '' ? $account_phone : __('暂未绑定', 'aksrt'); -$email_action = $account_email !== '' ? __('修改', 'aksrt') : __('绑定', 'aksrt'); -$phone_action = $account_phone !== '' ? __('修改', 'aksrt') : __('绑定', 'aksrt'); +$agreement = function_exists('aksrt_get_auth_agreement_view') ? aksrt_get_auth_agreement_view() : ['enabled' => false, 'required' => false, 'label_html' => '']; +$email_verify_required = $account_email !== '' && !empty($user_settings['email_set_captch']); +$reset_channel = class_exists('\\AKSRT\\Settings\\Settings') ? \AKSRT\Settings\Settings::reset_password_verification_type() : 'email'; +$sign_in_available = !class_exists('\\AKSRT\\Settings\\Settings') || (!\AKSRT\Settings\Settings::close_sign() && !\AKSRT\Settings\Settings::close_signin()); +$can_recover_password = $sign_in_available && ($reset_channel === 'email_phone' + ? ($account_email !== '' || $account_phone !== '') + : ($reset_channel === 'phone' ? $account_phone !== '' : $account_email !== '')); +$recovery_target = $reset_channel === 'phone' ? $account_phone : ($account_email !== '' ? $account_email : $account_phone); +$recovery_channel = $reset_channel === 'email_phone' ? '' : $reset_channel; $render_row = static function (string $icon, string $title, string $value, string $action, string $modal, string $icon_tone, string $action_tone, bool $interactive = true): void { ?> @@ -28,116 +32,122 @@ $render_row = static function (string $icon, string $title, string $value, strin - -
- +
- - - - + + +
+ + + + + +
+ +
+ + + + + +
+
- - + +
- +
- - + +
+
- - + +
diff --git a/inc/modules/user-parts/account-ajax.php b/inc/modules/user-parts/account-ajax.php index 7d576222..c2e74da2 100644 --- a/inc/modules/user-parts/account-ajax.php +++ b/inc/modules/user-parts/account-ajax.php @@ -1,322 +1,365 @@ $result->get_error_code() ?: 'aksrt_captcha_failed', - 'message' => $result->get_error_message() ?: __('人机验证失败,请重试', 'aksrt'), - ]; - } - return [ - 'code' => 'aksrt_captcha_failed', - 'message' => __('人机验证失败,请重试', 'aksrt'), - ]; +if (!function_exists('aksrt_account_captcha_error_data')) { + function aksrt_account_captcha_error_data($result): array { + return [ + 'code' => is_wp_error($result) ? ($result->get_error_code() ?: 'aksrt_captcha_failed') : 'aksrt_captcha_failed', + 'message' => is_wp_error($result) + ? ($result->get_error_message() ?: __('人机验证失败,请重试', 'aksrt')) + : __('人机验证失败,请重试', 'aksrt'), + ]; + } } -endif; -// ============================================================ -add_action('wp_ajax_aksrt_save_profile', function () { - if (!isset($_POST['_profile_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_profile_nonce'])), 'aksrt_profile_save')) { - wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); - } +add_action('wp_ajax_aksrt_save_profile', function (): void { + if ( + !isset($_POST['_profile_nonce']) + || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_profile_nonce'])), 'aksrt_profile_save') + ) { + wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); + } - $user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0; - $result = function_exists('aksrt_save_user_center_profile') - ? aksrt_save_user_center_profile($user_id, $_POST) - : new WP_Error('aksrt_profile_unavailable', __('保存失败', 'aksrt')); + $user_id = (int) ($_POST['user_id'] ?? 0); + $result = function_exists('aksrt_save_user_center_profile') + ? aksrt_save_user_center_profile($user_id, $_POST) + : new WP_Error('aksrt_profile_unavailable', __('保存失败', 'aksrt')); - if (is_wp_error($result)) { - wp_send_json_error(['message' => $result->get_error_message()]); - } - - wp_send_json_success(['message' => __('保存成功!', 'aksrt')]); + if (is_wp_error($result)) wp_send_json_error(['message' => $result->get_error_message()]); + wp_send_json_success(['message' => __('保存成功!', 'aksrt')]); }); -// ============================================================ -// AJAX: 发送邮箱验证码(修改密码/更改邮箱通用) -// ============================================================ - -if (!function_exists('aksrt_get_email_code_key')) : -function aksrt_get_email_code_key(int $user_id, string $scene): string { - return 'aksrt_email_code_' . $scene . '_' . $user_id; -} -endif; - -if (!function_exists('aksrt_get_email_code_rate_key')) : -function aksrt_get_email_code_rate_key(int $user_id, string $scene, string $target = ''): string { - $suffix = $target !== '' ? '_' . md5(strtolower($target)) : ''; - return 'aksrt_email_code_rate_' . $scene . '_' . $user_id . $suffix; -} -endif; - -if (!function_exists('aksrt_store_email_code')) : -function aksrt_store_email_code(int $user_id, string $scene, string $code, string $target = ''): void { - set_transient( - aksrt_get_email_code_key($user_id, $scene), - [ - 'hash' => wp_hash_password($code), - 'target' => $target, - 'issued_at' => time(), - ], - 5 * MINUTE_IN_SECONDS - ); -} -endif; - -if (!function_exists('aksrt_consume_email_code')) : -function aksrt_consume_email_code(int $user_id, string $scene, string $code, string $target = ''): bool { - $key = aksrt_get_email_code_key($user_id, $scene); - $stored = get_transient($key); - if (!is_array($stored) || empty($stored['hash']) || !wp_check_password($code, $stored['hash'])) { - return false; - } - if ($target !== '' && strtolower((string) ($stored['target'] ?? '')) !== strtolower($target)) { - return false; - } - - delete_transient($key); - return true; +function aksrt_account_code_key(int $user_id, string $scene, string $target): string { + return 'aksrt_account_code_' . $user_id . '_' . sanitize_key($scene) . '_' . md5(strtolower(trim($target))); } -endif; +function aksrt_account_store_code(int $user_id, string $scene, string $target, string $code): void { + set_transient( + aksrt_account_code_key($user_id, $scene, $target), + ['hash' => wp_hash_password($code), 'attempts' => 0], + 5 * MINUTE_IN_SECONDS + ); +} -add_action('wp_ajax_aksrt_send_email_code', function () { - if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { - wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); - } +function aksrt_account_consume_code(int $user_id, string $scene, string $target, string $code, bool $consume = true): bool { + $key = aksrt_account_code_key($user_id, $scene, $target); + $stored = get_transient($key); + if (!is_array($stored) || empty($stored['hash'])) { + return false; + } - if ( ! is_user_logged_in() ) { - wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); - } + $attempts = (int) ($stored['attempts'] ?? 0) + 1; + if ($attempts > 6) { + delete_transient($key); + return false; + } - $scene = isset( $_POST['scene'] ) ? sanitize_key( (string) $_POST['scene'] ) : ''; - if ( ! in_array( $scene, array( 'change_password', 'change_email' ), true ) ) { - wp_send_json_error( array( 'message' => __( '无效的验证场景', 'aksrt' ) ) ); - } + if (!wp_check_password($code, (string) $stored['hash'])) { + $stored['attempts'] = $attempts; + set_transient($key, $stored, 5 * MINUTE_IN_SECONDS); + return false; + } - if ( function_exists( 'aksrt_require_captcha' ) ) { - $captcha_result = aksrt_require_captcha( $scene, wp_unslash( $_POST ) ); - if ( $captcha_result !== true ) { - wp_send_json_error( aksrt_account_captcha_error_data( $captcha_result ) ); - } - } + if ($consume) { + delete_transient($key); + } + return true; +} - $current_user = wp_get_current_user(); - $target_email = ''; - $success_message = ''; +function aksrt_account_proof_key(int $user_id, string $kind): string { + return 'aksrt_account_proof_' . sanitize_key($kind) . '_' . $user_id; +} - if ( $scene === 'change_password' ) { - $target_email = $current_user->user_email; - if ( empty( $target_email ) || ! is_email( $target_email ) ) { - wp_send_json_error( array( 'message' => __( '当前账号未绑定有效邮箱', 'aksrt' ) ) ); - } - $success_message = __( '验证码已发送到您的注册邮箱', 'aksrt' ); - } else { - $new_email = isset( $_POST['new_email'] ) ? trim( wp_unslash( $_POST['new_email'] ) ) : ''; - $current_password = isset( $_POST['current_password'] ) ? trim( wp_unslash( $_POST['current_password'] ) ) : ''; +function aksrt_account_set_proof(int $user_id, string $kind, string $target): void { + set_transient( + aksrt_account_proof_key($user_id, $kind), + ['target' => strtolower(trim($target))], + 30 * MINUTE_IN_SECONDS + ); +} - if ( empty( $current_password ) || ! wp_check_password( $current_password, $current_user->user_pass, $current_user->ID ) ) { - wp_send_json_error( array( 'message' => __( '当前密码不正确', 'aksrt' ) ) ); - } - if ( empty( $new_email ) || ! is_email( $new_email ) ) { - wp_send_json_error( array( 'message' => __( '请输入有效的新邮箱地址', 'aksrt' ) ) ); - } - if ( strtolower( $new_email ) === strtolower( $current_user->user_email ) ) { - wp_send_json_error( array( 'message' => __( '新邮箱与当前邮箱相同', 'aksrt' ) ) ); - } - if ( email_exists( $new_email ) ) { - wp_send_json_error( array( 'message' => __( '该邮箱已被其他账号使用', 'aksrt' ) ) ); - } +function aksrt_account_consume_proof(int $user_id, string $kind, string $target, bool $consume = true): bool { + $key = aksrt_account_proof_key($user_id, $kind); + $proof = get_transient($key); + if (!is_array($proof) || strtolower(trim((string) ($proof['target'] ?? ''))) !== strtolower(trim($target))) { + return false; + } - $target_email = $new_email; - $success_message = __( '验证码已发送到新邮箱,请完成验证后再提交', 'aksrt' ); - } + if ($consume) { + delete_transient($key); + } + return true; +} - // 频率限制:60秒内不能重复发送 - $rate_key = aksrt_get_email_code_rate_key( $current_user->ID, $scene, $target_email ); - if ( get_transient( $rate_key ) ) { - wp_send_json_error( array( 'message' => __( '操作过于频繁,请稍后再试', 'aksrt' ) ) ); - } - set_transient( $rate_key, true, 60 ); +function aksrt_account_rate_limited(int $user_id, string $scene): bool { + $key = 'aksrt_account_rate_' . sanitize_key($scene) . '_' . $user_id; + $count = (int) get_transient($key); + if ($count >= 8) { + return true; + } - // 生成验证码并同步发送,只有真正发信成功后才写入验证码缓存。 - $code = str_pad( (string) random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT ); + set_transient($key, $count + 1, 10 * MINUTE_IN_SECONDS); + return false; +} - $site_name = get_bloginfo( 'name' ); - $sent = \AKSRT\Settings\Settings::send_event_email( - 'change_code', - $target_email, - [ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{code}' => $code ] - ); +function aksrt_account_reset_rate(int $user_id, string $scene): void { + delete_transient('aksrt_account_rate_' . sanitize_key($scene) . '_' . $user_id); +} - if ( ! $sent ) { - delete_transient( $rate_key ); - error_log( '[AKSRT] 验证码邮件发送失败 user_id=' . $current_user->ID . ' email=' . $target_email . ' scene=' . $scene ); - wp_send_json_error( array( 'message' => __( '验证码邮件发送失败,请稍后重试', 'aksrt' ) ) ); - } +function aksrt_account_email_verification_required(): bool { + $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; + return !empty($settings['email_set_captch']); +} - aksrt_store_email_code( $current_user->ID, $scene, $code, $target_email ); +add_action('wp_ajax_aksrt_send_account_email_code', function (): void { + if ( + !isset($_POST['_settings_nonce']) + || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_settings_nonce'])), 'aksrt_account_settings') + ) { + wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); + } - wp_send_json_success( array( 'message' => $success_message ) ); + $user_id = get_current_user_id(); + $user = wp_get_current_user(); + if (!$user_id || !($user instanceof WP_User)) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + $scene = sanitize_key((string) wp_unslash($_POST['scene'] ?? '')); + if (!in_array($scene, ['verify_email', 'change_email'], true)) { + wp_send_json_error(['message' => __('无效的验证场景', 'aksrt')]); + } + if (!aksrt_account_email_verification_required()) { + wp_send_json_error(['message' => __('邮箱验证码验证未开启', 'aksrt')]); + } + + $target = $scene === 'verify_email' + ? (string) $user->user_email + : sanitize_email((string) wp_unslash($_POST['new_email'] ?? '')); + if (!is_email($target)) { + wp_send_json_error(['message' => __('邮箱格式错误', 'aksrt')]); + } + if ($scene === 'change_email') { + if (strtolower($target) === strtolower((string) $user->user_email)) { + wp_send_json_error(['message' => __('修改邮箱不能与现邮箱相同', 'aksrt')]); + } + if (email_exists($target)) { + wp_send_json_error(['message' => __('该邮箱已有绑定账号', 'aksrt')]); + } + } + + if (function_exists('aksrt_require_captcha')) { + $captcha = aksrt_require_captcha('change_email', wp_unslash($_POST)); + if ($captcha !== true) { + wp_send_json_error(aksrt_account_captcha_error_data($captcha)); + } + } + $rate_key = 'aksrt_account_email_rate_' . $user_id . '_' . $scene . '_' . md5(strtolower($target)); + if (get_transient($rate_key)) { + wp_send_json_error(['message' => __('操作过于频繁,请稍后再试', 'aksrt')]); + } + set_transient($rate_key, true, 60); + + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $sent = class_exists('\AKSRT\Settings\Settings') + ? \AKSRT\Settings\Settings::send_event_email('change_code', $target, [ + '{username}' => $user->display_name, + '{site_name}' => get_bloginfo('name'), + '{code}' => $code, + ]) + : false; + if (!$sent) { + delete_transient($rate_key); + wp_send_json_error(['message' => __('验证码邮件发送失败,请稍后重试', 'aksrt')]); + } + + aksrt_account_store_code($user_id, $scene, $target, $code); + wp_send_json_success([ + 'message' => $scene === 'verify_email' + ? __('验证码已发送到您的老邮箱', 'aksrt') + : __('验证码已发送到新邮箱', 'aksrt'), + 'countdown' => 60, + ]); }); -// ============================================================ -// AJAX: 修改密码 -// ============================================================ -add_action('wp_ajax_aksrt_change_password', function () { - if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { - wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); - } +add_action('wp_ajax_aksrt_verify_account_email', function (): void { + if ( + !isset($_POST['_settings_nonce']) + || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_settings_nonce'])), 'aksrt_account_settings') + ) { + wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); + } - $user_id = get_current_user_id(); - if ( ! $user_id ) { - wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); - } + $user_id = get_current_user_id(); + $user = wp_get_current_user(); + if (!$user_id || !($user instanceof WP_User) || !is_email((string) $user->user_email)) { + wp_send_json_error(['message' => __('当前账号未绑定有效邮箱', 'aksrt')]); + } + if (!aksrt_account_email_verification_required()) { + wp_send_json_error(['message' => __('邮箱验证码验证未开启', 'aksrt')]); + } + if (function_exists('aksrt_require_captcha')) { + $captcha = aksrt_require_captcha('change_email', wp_unslash($_POST)); + if ($captcha !== true) { + wp_send_json_error(aksrt_account_captcha_error_data($captcha)); + } + } - $old_password = isset( $_POST['old_password'] ) ? trim( wp_unslash( $_POST['old_password'] ) ) : ''; - $new_password = isset( $_POST['new_password'] ) ? trim( wp_unslash( $_POST['new_password'] ) ) : ''; - $new_password_confirm = isset( $_POST['new_password_confirm'] ) ? trim( wp_unslash( $_POST['new_password_confirm'] ) ) : ''; - $email_code = isset( $_POST['email_code'] ) ? sanitize_text_field( $_POST['email_code'] ) : ''; + $code = sanitize_text_field((string) wp_unslash($_POST['code'] ?? '')); + if (!aksrt_account_consume_code($user_id, 'verify_email', (string) $user->user_email, $code)) { + wp_send_json_error(['message' => __('验证码错误或已过期', 'aksrt')]); + } - // 验证原密码 - $user = get_userdata( $user_id ); - if ( ! $user || ! wp_check_password( $old_password, $user->data->user_pass, $user_id ) ) { - wp_send_json_error( array( 'message' => __( '原密码不正确', 'aksrt' ) ) ); - } - - // 验证新密码强度 - if ( mb_strlen( $new_password ) < 6 ) { - wp_send_json_error( array( 'message' => __( '新密码长度至少6位', 'aksrt' ) ) ); - } - if ( mb_strlen( $new_password ) > 128 ) { - wp_send_json_error( array( 'message' => __( '新密码过长', 'aksrt' ) ) ); - } - if ( $new_password !== $new_password_confirm ) { - wp_send_json_error( array( 'message' => __( '两次输入的新密码不一致', 'aksrt' ) ) ); - } - - // 人机验证 - if ( function_exists( 'aksrt_require_captcha' ) ) { - $captcha_result = aksrt_require_captcha( 'change_password', wp_unslash( $_POST ) ); - if ( $captcha_result !== true ) { - wp_send_json_error( aksrt_account_captcha_error_data( $captcha_result ) ); - } - } - - // 验证邮箱验证码 - if ( ! aksrt_consume_email_code( $user_id, 'change_password', $email_code, $user->user_email ) ) { - wp_send_json_error( array( 'message' => __( '邮箱验证码错误或已过期', 'aksrt' ) ) ); - } - - // 设置新密码 - wp_set_password( $new_password, $user_id ); - - if ( class_exists( 'WP_Session_Tokens' ) ) { - WP_Session_Tokens::get_instance( $user_id )->destroy_all(); - } - - // 清除登录态 - wp_clear_auth_cookie(); - - wp_send_json_success( array( - 'message' => __( '密码修改成功,即将跳转到登录页…', 'aksrt' ), - 'redirect_to' => aksrt_get_login_url(), - )); + aksrt_account_set_proof($user_id, 'email_old', (string) $user->user_email); + wp_send_json_success(['message' => __('验证成功', 'aksrt')]); }); -// ============================================================ -// AJAX: 更改邮箱 -// ============================================================ -add_action('wp_ajax_aksrt_change_email', function () { - if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { - wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); - } +add_action('wp_ajax_aksrt_change_password', function (): void { + if ( + !isset($_POST['_settings_nonce']) + || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_settings_nonce'])), 'aksrt_account_settings') + ) { + wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); + } - $user_id = get_current_user_id(); - if ( ! $user_id ) { - wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); - } + $user_id = get_current_user_id(); + if (!$user_id) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + $user = get_userdata($user_id); + if (!($user instanceof WP_User)) { + wp_send_json_error(['message' => __('账号信息读取失败,请刷新页面重试', 'aksrt')]); + } - $current_password = isset( $_POST['current_password'] ) ? trim( wp_unslash( $_POST['current_password'] ) ) : ''; - $new_email = isset( $_POST['new_email'] ) ? trim( wp_unslash( $_POST['new_email'] ) ) : ''; - $email_code = isset( $_POST['email_code'] ) ? sanitize_text_field( $_POST['email_code'] ) : ''; + $oauth_new = (bool) get_user_meta($user_id, 'oauth_new', true); + $password = trim((string) wp_unslash($_POST['password'] ?? '')); + $password2 = trim((string) wp_unslash($_POST['password2'] ?? '')); + if (function_exists('aksrt_require_captcha')) { + $captcha = aksrt_require_captcha('change_password', wp_unslash($_POST)); + if ($captcha !== true) { + wp_send_json_error(aksrt_account_captcha_error_data($captcha)); + } + } - $current_user = wp_get_current_user(); - if ( empty( $current_password ) || ! wp_check_password( $current_password, $current_user->user_pass, $current_user->ID ) ) { - wp_send_json_error( array( 'message' => __( '当前密码不正确', 'aksrt' ) ) ); - } + if (!$oauth_new) { + $old_password = trim((string) wp_unslash($_POST['passwordold'] ?? '')); + if ($old_password === '') { + wp_send_json_error(['message' => __('请输入原密码', 'aksrt')]); + } + if ($old_password === $password) { + wp_send_json_error(['message' => __('新密码和原密码不能相同', 'aksrt')]); + } + if (aksrt_account_rate_limited($user_id, 'change_password')) { + wp_send_json_error(['message' => __('错误次数过多,请稍后再试', 'aksrt')]); + } + if (!wp_check_password($old_password, $user->user_pass, $user_id)) { + wp_send_json_error(['message' => __('原密码错误', 'aksrt')]); + } + } - // 验证新邮箱格式 - if ( empty( $new_email ) || ! is_email( $new_email ) ) { - wp_send_json_error( array( 'message' => __( '请输入有效的邮箱地址', 'aksrt' ) ) ); - } + $valid = class_exists('\AKSRT\Auth\Service') + ? \AKSRT\Auth\Service::validate_password($password, $password2) + : __('认证服务不可用', 'aksrt'); + if ($valid !== true) { + wp_send_json_error(['message' => (string) $valid]); + } - if ( strtolower( $new_email ) === strtolower( $current_user->user_email ) ) { - wp_send_json_error( array( 'message' => __( '新邮箱与当前邮箱相同', 'aksrt' ) ) ); - } + $result = wp_update_user(['ID' => $user_id, 'user_pass' => $password]); + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } - // 检查邮箱是否已被使用 - if ( email_exists( $new_email ) ) { - wp_send_json_error( array( 'message' => __( '该邮箱已被其他账号使用', 'aksrt' ) ) ); - } - - // 人机验证 - if ( function_exists( 'aksrt_require_captcha' ) ) { - $captcha_result = aksrt_require_captcha( 'change_email', wp_unslash( $_POST ) ); - if ( $captcha_result !== true ) { - wp_send_json_error( aksrt_account_captcha_error_data( $captcha_result ) ); - } - } - - // 验证邮箱验证码 - if ( ! aksrt_consume_email_code( $user_id, 'change_email', $email_code, $new_email ) ) { - wp_send_json_error( array( 'message' => __( '邮箱验证码错误或已过期', 'aksrt' ) ) ); - } - - // 更新邮箱 - $old_email = $current_user->user_email; - $update_result = wp_update_user( [ - 'ID' => $user_id, - 'user_email' => $new_email, - ] ); - - if ( is_wp_error( $update_result ) ) { - wp_send_json_error( array( 'message' => $update_result->get_error_message() ?: __( '邮箱更新失败,请稍后重试', 'aksrt' ) ) ); - } - - if ( is_email( $old_email ) ) { - $site_name = get_bloginfo( 'name' ); - \AKSRT\Settings\Settings::send_event_email( - 'change_notice', - $old_email, - [ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{new_email}' => $new_email ] - ); - } - - $verify_url = ''; - $verify_message = ''; - update_user_meta( $user_id, 'aksrt_email_verified', 1 ); - - if ( class_exists( 'WP_Session_Tokens' ) ) { - WP_Session_Tokens::get_instance( $user_id )->destroy_all(); - } - - // 清除登录态 - wp_clear_auth_cookie(); - - wp_send_json_success( array( - 'message' => $verify_message !== '' - ? __( '邮箱修改成功,请前往新邮箱完成验证后重新登录…', 'aksrt' ) - : __( '邮箱修改成功,即将跳转到登录页…', 'aksrt' ), - 'redirect_to' => $verify_url !== '' ? $verify_url : aksrt_get_login_url(), - 'verify_url' => $verify_url, - )); + delete_user_meta($user_id, 'oauth_new'); + aksrt_account_reset_rate($user_id, 'change_password'); + wp_send_json_success([ + 'message' => $oauth_new + ? __('密码设置成功', 'aksrt') + : __('修改成功,下次请使用新密码登录', 'aksrt'), + 'reload' => true, + ]); +}); + +add_action('wp_ajax_aksrt_change_email', function (): void { + if ( + !isset($_POST['_settings_nonce']) + || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_settings_nonce'])), 'aksrt_account_settings') + ) { + wp_send_json_error(['message' => __('安全验证失败,请刷新页面重试', 'aksrt')]); + } + + $user_id = get_current_user_id(); + $user = wp_get_current_user(); + if (!$user_id || !($user instanceof WP_User)) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + $new_email = sanitize_email((string) wp_unslash($_POST['new_email'] ?? '')); + if (!is_email($new_email)) { + wp_send_json_error(['message' => __('邮箱格式错误', 'aksrt')]); + } + if ($user->user_email !== '' && strtolower($new_email) === strtolower((string) $user->user_email)) { + wp_send_json_error(['message' => __('修改邮箱不能与现邮箱相同', 'aksrt')]); + } + if (email_exists($new_email)) { + wp_send_json_error(['message' => __('该邮箱已有绑定账号', 'aksrt')]); + } + + $agreement = function_exists('aksrt_get_auth_agreement_view') ? aksrt_get_auth_agreement_view() : ['enabled' => false, 'required' => false]; + if (!empty($agreement['required']) && empty($_POST['agree_terms'])) { + wp_send_json_error(['message' => __('请先同意相关协议', 'aksrt')]); + } + if (function_exists('aksrt_require_captcha')) { + $captcha = aksrt_require_captcha('change_email', wp_unslash($_POST)); + if ($captcha !== true) { + wp_send_json_error(aksrt_account_captcha_error_data($captcha)); + } + } + + $verification_required = aksrt_account_email_verification_required(); + $old_email = (string) $user->user_email; + if ( + $old_email !== '' + && $verification_required + && !aksrt_account_consume_proof($user_id, 'email_old', $old_email, false) + ) { + wp_send_json_error(['message' => __('请先验证老邮箱', 'aksrt')]); + } + if ( + $verification_required + && !aksrt_account_consume_code( + $user_id, + 'change_email', + $new_email, + sanitize_text_field((string) wp_unslash($_POST['code'] ?? '')), + false + ) + ) { + wp_send_json_error(['message' => __('验证码错误或已过期', 'aksrt')]); + } + + $result = wp_update_user(['ID' => $user_id, 'user_email' => $new_email]); + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message() ?: __('邮箱更新失败,请稍后重试', 'aksrt')]); + } + + if ($old_email !== '' && $verification_required) { + delete_transient(aksrt_account_proof_key($user_id, 'email_old')); + } + if ($verification_required) { + delete_transient(aksrt_account_code_key($user_id, 'change_email', $new_email)); + } + update_user_meta($user_id, 'aksrt_email_verified', 1); + if ($old_email !== '' && is_email($old_email) && class_exists('\AKSRT\Settings\Settings')) { + \AKSRT\Settings\Settings::send_event_email('change_notice', $old_email, [ + '{username}' => $user->display_name, + '{site_name}' => get_bloginfo('name'), + '{new_email}' => $new_email, + ]); + } + + wp_send_json_success([ + 'message' => $old_email !== '' ? __('邮箱修改成功', 'aksrt') : __('邮箱绑定成功', 'aksrt'), + 'reload' => true, + ]); }); diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index c6ff20b1..4a48d469 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -205,6 +205,11 @@ class UserController extends ControllerBase { 'callback' => [$this, 'user_phone_code'], 'permission_callback' => static function () { return is_user_logged_in(); }, ]); + register_rest_route(self::API_NAMESPACE, '/user/phone-verify', [ + 'methods' => 'POST', + 'callback' => [$this, 'user_verify_phone'], + 'permission_callback' => static function () { return is_user_logged_in(); }, + ]); register_rest_route(self::API_NAMESPACE, '/user/phone', [ 'methods' => 'POST', 'callback' => [$this, 'user_bind_phone'], diff --git a/src/Admin/UserControllerAuthPhoneOperations.php b/src/Admin/UserControllerAuthPhoneOperations.php index f378673c..8a2e88c8 100644 --- a/src/Admin/UserControllerAuthPhoneOperations.php +++ b/src/Admin/UserControllerAuthPhoneOperations.php @@ -80,7 +80,7 @@ trait UserControllerAuthPhoneOperations { ], $ttl); } - private function auth_consume_code(string $scene, string $target, string $code): bool { + private function auth_consume_code(string $scene, string $target, string $code, bool $consume = true): bool { $key = $this->auth_code_key($scene, $target); $stored = get_transient($key); if (!is_array($stored) || empty($stored['hash'])) { @@ -101,7 +101,9 @@ trait UserControllerAuthPhoneOperations { set_transient($key, $stored, $remaining); return false; } - delete_transient($key); + if ($consume) { + delete_transient($key); + } return true; } @@ -459,38 +461,85 @@ trait UserControllerAuthPhoneOperations { public function user_phone_code(\WP_REST_Request $request): \WP_REST_Response { $payload = $this->auth_payload($request); - if (!$this->auth_verify_rest_nonce($request, $payload)) { - return $this->error(__('安全验证失败', 'aksrt'), 403); - } + if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403); + $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; + if (empty($settings['bind_phone'])) return $this->error(__('手机号绑定功能暂未开启', 'aksrt'), 403); + $stage = sanitize_key((string) ($payload['stage'] ?? 'bind_new_phone')); $phone = sanitize_text_field((string) ($payload['phone'] ?? '')); - if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) { - return $this->error(__('手机号格式不正确', 'aksrt')); + if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) return $this->error(__('手机号格式不正确', 'aksrt')); + $user_id = get_current_user_id(); + $current_phone = (string) get_user_meta($user_id, 'phone_number', true); + if ($stage === 'verify_old_phone') { + if ($current_phone === '' || !hash_equals($current_phone, $phone)) return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409); + } elseif ($stage === 'bind_new_phone') { + if ($current_phone !== '' && !hash_equals($current_phone, $phone)) { + $proof = get_transient('aksrt_account_phone_old_' . $user_id); + if (!is_array($proof) || !hash_equals($current_phone, (string) ($proof['phone'] ?? ''))) return $this->error(__('请先验证老手机号', 'aksrt'), 403); + } + $users = get_users(['meta_key' => 'phone_number', 'meta_value' => $phone, 'number' => 1, 'fields' => 'ID']); + if (!empty($users) && (int) $users[0] !== $user_id) return $this->error(__('该手机号已被其他账号绑定', 'aksrt'), 409); + if ($current_phone !== '' && hash_equals($current_phone, $phone)) return $this->error(__('手机号不能与当前绑定号码相同', 'aksrt'), 409); + } else { + return $this->error(__('无效的验证步骤', 'aksrt')); } $captcha_error = $this->auth_require_captcha('bind_phone', $payload); if ($captcha_error) return $captcha_error; if (!class_exists('\AKSRT\Sms\SMS')) return $this->error(__('短信服务不可用', 'aksrt'), 503); + $rate_subject = $stage . '|' . $user_id . '|' . $phone; + if ($this->auth_rate_limited('account-phone-code', $rate_subject, 5, HOUR_IN_SECONDS)) return $this->error(__('验证码请求过于频繁,请稍后再试', 'aksrt'), 429); $code = (string) random_int(100000, 999999); $result = \AKSRT\Sms\SMS::get_instance()->send_code($phone, $code); - if (empty($result['success'])) { - return $this->error((string) ($result['message'] ?? __('发送失败', 'aksrt'))); - } - $this->auth_store_code('bind_phone', $phone, $code, 30 * MINUTE_IN_SECONDS); + if (empty($result['success'])) return $this->error((string) ($result['message'] ?? __('发送失败', 'aksrt'))); + $this->auth_store_code('account_' . $stage . '_' . $user_id, $phone, $code, 30 * MINUTE_IN_SECONDS); return $this->success(['message' => __('验证码短信已发送', 'aksrt'), 'countdown' => (int) ($result['remaining_time'] ?? 60)]); } + public function user_verify_phone(\WP_REST_Request $request): \WP_REST_Response { + $payload = $this->auth_payload($request); + if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403); + $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; + if (empty($settings['bind_phone'])) return $this->error(__('手机号绑定功能暂未开启', 'aksrt'), 403); + $captcha_error = $this->auth_require_captcha('bind_phone', $payload); + if ($captcha_error) return $captcha_error; + $user_id = get_current_user_id(); + $current_phone = (string) get_user_meta($user_id, 'phone_number', true); + $phone = sanitize_text_field((string) ($payload['phone'] ?? '')); + $code = sanitize_text_field((string) ($payload['code'] ?? '')); + if ($current_phone === '' || !hash_equals($current_phone, $phone)) return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409); + if (!$this->auth_consume_code('account_verify_old_phone_' . $user_id, $current_phone, $code)) return $this->error(__('验证码错误或已过期', 'aksrt')); + set_transient('aksrt_account_phone_old_' . $user_id, ['phone' => $current_phone], 30 * MINUTE_IN_SECONDS); + return $this->success(['message' => __('手机号验证成功', 'aksrt')]); + } + public function user_bind_phone(\WP_REST_Request $request): \WP_REST_Response { $payload = $this->auth_payload($request); if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403); $captcha_error = $this->auth_require_captcha('bind_phone', $payload); if ($captcha_error) return $captcha_error; + $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; + if (empty($settings['bind_phone'])) return $this->error(__('手机号绑定功能暂未开启', 'aksrt'), 403); + $user_id = get_current_user_id(); + $current_phone = (string) get_user_meta($user_id, 'phone_number', true); $phone = sanitize_text_field((string) ($payload['phone'] ?? '')); $code = sanitize_text_field((string) ($payload['code'] ?? '')); - if (!$this->auth_consume_code('bind_phone', $phone, $code)) { - return $this->error(__('验证码错误或已过期', 'aksrt')); + if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) return $this->error(__('手机号格式不正确', 'aksrt')); + if ($current_phone !== '') { + $proof = get_transient('aksrt_account_phone_old_' . $user_id); + if (!is_array($proof) || !hash_equals($current_phone, (string) ($proof['phone'] ?? ''))) return $this->error(__('请先验证老手机号', 'aksrt'), 403); + if (hash_equals($current_phone, $phone)) return $this->error(__('手机号不能与当前绑定号码相同', 'aksrt'), 409); } + $users = get_users(['meta_key' => 'phone_number', 'meta_value' => $phone, 'number' => 1, 'fields' => 'ID']); + if (!empty($users) && (int) $users[0] !== $user_id) return $this->error(__('该手机号已被其他账号绑定', 'aksrt'), 409); + $agreement = function_exists('aksrt_get_auth_agreement_view') ? aksrt_get_auth_agreement_view() : ['enabled' => false, 'required' => false]; + if (!empty($agreement['required']) && empty($payload['agree_terms'])) return $this->error(__('请先同意相关协议', 'aksrt'), 400); + $code_scene = 'account_bind_new_phone_' . $user_id; + if (!$this->auth_consume_code($code_scene, $phone, $code, false)) return $this->error(__('验证码错误或已过期', 'aksrt')); if (!class_exists('\AKSRT\Auth\Service')) return $this->error(__('认证服务不可用', 'aksrt'), 500); $result = \AKSRT\Auth\Service::bind_phone($payload, true); - return !empty($result['success']) ? $this->success(['message' => $result['message'] ?? __('绑定成功', 'aksrt')]) : $this->error((string) ($result['error'] ?? __('绑定失败', 'aksrt'))); + if (empty($result['success'])) return $this->error((string) ($result['error'] ?? __('绑定失败', 'aksrt'))); + delete_transient($this->auth_code_key($code_scene, $phone)); + delete_transient('aksrt_account_phone_old_' . $user_id); + return $this->success(['message' => $result['message'] ?? __('绑定成功', 'aksrt')]); } public function test_sms_send(\WP_REST_Request $request): \WP_REST_Response { diff --git a/tests/widget-system/account-settings-contract.test.cjs b/tests/widget-system/account-settings-contract.test.cjs new file mode 100644 index 00000000..1ed52459 --- /dev/null +++ b/tests/widget-system/account-settings-contract.test.cjs @@ -0,0 +1,111 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('account dialogs expose configured email steps, phone steps, and reference password branches', () => { + const template = read('inc/helpers/user-center-renderers/settings-account.php'); + const javascript = read('assets/js/routes/user-center.js'); + const css = read('assets/css/routes/user-center/settings.css'); + + assert.match(template, /\$email_verify_required = \$account_email !== '' && !empty\(\$user_settings\['email_set_captch'\]\)/); + assert.match(template, /data-account-step="email-old"[\s\S]*?data-account-step="email-new"[\s\S]*?data-account-step="email-success"/); + assert.match(template, /if \(\$email_verify_required\)/); + assert.match(template, /if \(\$email_verify_required\)[\s\S]*?else : \?>[\s\S]*?aksrt-settings-modal__pane/); + assert.match(template, /if \(!empty\(\$user_settings\['email_set_captch'\]\)\)/); + assert.match(template, /data-account-step="phone-old"[\s\S]*?data-account-step="phone-new"[\s\S]*?data-account-step="phone-success"/); + assert.match(template, /if \(\$account_phone !== ''\)[\s\S]*?else : \?>[\s\S]*?aksrt-settings-modal__pane/); + assert.match(template, /data-aksrt-sms-scene="verify_old_phone"/); + assert.match(template, /data-aksrt-sms-scene="bind_new_phone"/); + assert.match(template, /if \(!\$account_oauth_new\)[\s\S]*?name="passwordold"/); + assert.match(template, /if \(!\$account_oauth_new && \$can_recover_password\)/); + assert.match(template, /data-account-password-pane="recovery"/); + assert.match(javascript, /\/auth\/code/); + assert.match(javascript, /\/auth\/reset-password/); + assert.doesNotMatch(template + javascript + css, /pwd-strength|pwd-send-code|email-send-code/); + + const emailConditionStart = template.indexOf(''); + const emailElse = template.indexOf('', emailConditionStart); + const emailEnd = template.indexOf('', emailElse); + const emailSteps = template.slice(emailConditionStart, emailElse); + const emailDirect = template.slice(emailElse, emailEnd); + const phoneConditionStart = template.indexOf(''); + const phoneElse = template.indexOf('', phoneConditionStart); + const phoneEnd = template.indexOf('', phoneElse); + const phoneSteps = template.slice(phoneConditionStart, phoneElse); + const phoneDirect = template.slice(phoneElse, phoneEnd); + assert.match(emailSteps, /account-step-list/); + assert.doesNotMatch(emailDirect, /account-step-list|data-account-step=/); + assert.match(phoneSteps, /account-step-list/); + assert.doesNotMatch(phoneDirect, /account-step-list|data-account-step=/); +}); + +test('account PHP harness exercises email and password state transitions', () => { + const harness = path.join(root, 'tests', 'widget-system', 'account-settings-harness.php'); + const output = execFileSync('php', [harness], { encoding: 'utf8' }); + assert.match(output, /Account settings harness passed/); +}); + +test('email update keeps both proofs through validation failures and consumes them only after the write', () => { + const ajax = read('inc/modules/user-parts/account-ajax.php'); + const send = ajax.slice(ajax.indexOf("add_action('wp_ajax_aksrt_send_account_email_code'"), ajax.indexOf("add_action('wp_ajax_aksrt_verify_account_email'")); + const verify = ajax.slice(ajax.indexOf("add_action('wp_ajax_aksrt_verify_account_email'"), ajax.indexOf("add_action('wp_ajax_aksrt_change_password'")); + const change = ajax.slice(ajax.indexOf("add_action('wp_ajax_aksrt_change_email'")); + + assert.match(send, /\['verify_email', 'change_email'\]/); + assert.match(send, /email_exists\(\$target\)/); + assert.match(verify, /aksrt_require_captcha\(\s*'change_email'/); + assert.match(verify, /aksrt_account_set_proof\(\$user_id, 'email_old'/); + assert.match(change, /aksrt_account_email_verification_required\(\)/); + assert.match(change, /aksrt_account_consume_proof\([^;]+false\)/); + assert.match(change, /aksrt_account_consume_code\([\s\S]*?false\s*\)/); + assert.ok(change.indexOf('wp_update_user(') < change.indexOf("delete_transient(aksrt_account_proof_key")); + assert.ok(change.indexOf('wp_update_user(') < change.indexOf("delete_transient(aksrt_account_code_key")); + assert.match(change, /\$agreement\['required'\]/); + assert.doesNotMatch(ajax, /wp_ajax_aksrt_send_email_code/); +}); + +test('phone replacement requires old-number proof and a new-number code while reusing bind side effects', () => { + const controller = read('src/Admin/UserControllerAuthPhoneOperations.php'); + const routes = read('src/Admin/UserController.php'); + const binding = read('src/Auth/ServicePart1b.php'); + const code = controller.slice(controller.indexOf('public function user_phone_code('), controller.indexOf('public function user_verify_phone(')); + const verify = controller.slice(controller.indexOf('public function user_verify_phone('), controller.indexOf('public function user_bind_phone(')); + const bind = controller.slice(controller.indexOf('public function user_bind_phone('), controller.indexOf('public function test_sms_send(')); + + assert.match(routes, /\/user\/phone-verify[\s\S]*?user_verify_phone/); + assert.match(code, /verify_old_phone/); + assert.match(code, /bind_new_phone/); + assert.match(verify, /auth_consume_code\('account_verify_old_phone_' \./); + assert.match(verify, /aksrt_account_phone_old_/); + assert.match(bind, /aksrt_account_phone_old_/); + assert.match(bind, /auth_consume_code\(\$code_scene, \$phone, \$code, false\)/); + assert.ok(bind.indexOf("Auth\\Service::bind_phone") < bind.indexOf("delete_transient($this->auth_code_key($code_scene, $phone))")); + assert.match(bind, /aksrt_get_auth_agreement_view/); + assert.match(bind, /Auth\\Service::bind_phone\(\$payload, true\)/); + assert.match(binding, /aksrt_user_phone_changed/); + assert.doesNotMatch(controller, /auth_consume_code\('bind_phone'/); +}); + +test('password change retains old-password limits and captcha; recovery uses shared auth endpoints', () => { + const ajax = read('inc/modules/user-parts/account-ajax.php'); + const javascript = read('assets/js/routes/user-center.js'); + const password = ajax.slice(ajax.indexOf("add_action('wp_ajax_aksrt_change_password'"), ajax.indexOf("add_action('wp_ajax_aksrt_change_email'")); + + assert.match(password, /get_user_meta\(\$user_id, 'oauth_new', true\)/); + assert.match(password, /wp_check_password\(\$old/); + assert.match(password, /aksrt_account_rate_limited\(\$user_id, 'change_password'\)/); + assert.match(password, /aksrt_require_captcha\('change_password'/); + assert.match(password, /Auth\\Service::validate_password\(\$password, \$password2\)/); + assert.doesNotMatch(password, /email.*code|password.*strength/i); + assert.match(javascript, /scene: 'resetpassword', target: target, channel:/); + assert.match(javascript, /\/auth\/code/); + assert.match(javascript, /\/auth\/reset-password/); + assert.match(javascript, /data-recovery-channel/); +}); diff --git a/tests/widget-system/account-settings-harness.php b/tests/widget-system/account-settings-harness.php new file mode 100644 index 00000000..a40cd9df --- /dev/null +++ b/tests/widget-system/account-settings-harness.php @@ -0,0 +1,281 @@ + true]; + $GLOBALS['aksrt_account_user_id'] = 7; + $GLOBALS['aksrt_account_user'] = null; + $GLOBALS['aksrt_account_meta'] = []; + $GLOBALS['aksrt_account_transients'] = []; + $GLOBALS['aksrt_account_actions'] = []; + $GLOBALS['aksrt_account_mail'] = []; + $GLOBALS['aksrt_account_existing_emails'] = []; + $GLOBALS['aksrt_account_captcha_calls'] = []; + $GLOBALS['aksrt_account_captcha_fail'] = false; + $GLOBALS['aksrt_account_update_fail'] = false; + + final class WP_User { + public function __construct( + public int $ID, + public string $user_email, + public string $display_name, + public string $user_pass + ) {} + } + + final class WP_Error { + public function __construct( + private string $code = '', + private string $message = '', + private mixed $data = null + ) {} + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): mixed { return $this->data; } + } + + final class JsonResponseException extends \RuntimeException { + public function __construct(public array $payload) { parent::__construct('json_response'); } + } + + function add_action(string $hook, callable $callback): void { + $GLOBALS['aksrt_account_actions'][$hook] = $callback; + } + function __(string $text, string $domain = ''): string { return $text; } + function is_wp_error(mixed $value): bool { return $value instanceof WP_Error; } + function is_email(string $email): bool { return (bool) filter_var($email, FILTER_VALIDATE_EMAIL); } + function sanitize_email(string $email): string { return trim($email); } + function sanitize_key(string $key): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key)) ?? ''; } + function sanitize_text_field(string $value): string { return trim(strip_tags($value)); } + function wp_unslash(mixed $value): mixed { return $value; } + function wp_verify_nonce(string $nonce, string $action): bool { return $nonce === 'valid' && $action === 'aksrt_account_settings'; } + function get_current_user_id(): int { return (int) $GLOBALS['aksrt_account_user_id']; } + function wp_get_current_user(): mixed { return $GLOBALS['aksrt_account_user']; } + function get_userdata(int $user_id): ?WP_User { return $user_id === 7 ? $GLOBALS['aksrt_account_user'] : null; } + function get_bloginfo(string $show = ''): string { return 'AKSRT Test'; } + function wp_hash_password(string $password): string { return 'hash:' . $password; } + function wp_check_password(string $password, string $hash, int $user_id = 0): bool { return hash_equals('hash:' . $password, $hash); } + function email_exists(string $email): int|false { return $GLOBALS['aksrt_account_existing_emails'][strtolower($email)] ?? false; } + function set_transient(string $key, mixed $value, int $expiration = 0): bool { + $GLOBALS['aksrt_account_transients'][$key] = [ + 'value' => $value, + 'expires' => $expiration > 0 ? time() + $expiration : 0, + ]; + return true; + } + function get_transient(string $key): mixed { + $row = $GLOBALS['aksrt_account_transients'][$key] ?? null; + if (!is_array($row)) return false; + if (!empty($row['expires']) && $row['expires'] <= time()) { + unset($GLOBALS['aksrt_account_transients'][$key]); + return false; + } + return $row['value'] ?? false; + } + function delete_transient(string $key): bool { + unset($GLOBALS['aksrt_account_transients'][$key]); + return true; + } + function get_user_meta(int $user_id, string $key, bool $single = false): mixed { + return $GLOBALS['aksrt_account_meta'][$user_id][$key] ?? ''; + } + function update_user_meta(int $user_id, string $key, mixed $value): bool { + $GLOBALS['aksrt_account_meta'][$user_id][$key] = $value; + return true; + } + function delete_user_meta(int $user_id, string $key): bool { + unset($GLOBALS['aksrt_account_meta'][$user_id][$key]); + return true; + } + function wp_update_user(array $data): int|WP_Error { + if ($GLOBALS['aksrt_account_update_fail']) { + return new WP_Error('update_failed', 'update failed'); + } + $user = $GLOBALS['aksrt_account_user']; + if (isset($data['user_email'])) $user->user_email = (string) $data['user_email']; + if (isset($data['user_pass'])) $user->user_pass = wp_hash_password((string) $data['user_pass']); + return (int) $user->ID; + } + function aksrt_require_captcha(string $scene, array $payload): true|WP_Error { + $GLOBALS['aksrt_account_captcha_calls'][] = $scene; + return $GLOBALS['aksrt_account_captcha_fail'] ? new WP_Error('captcha_failed', 'captcha failed') : true; + } + function aksrt_get_auth_agreement_view(): array { return ['enabled' => true, 'required' => true]; } + function wp_send_json_error(array $data): never { throw new JsonResponseException(['success' => false, 'data' => $data]); } + function wp_send_json_success(array $data): never { throw new JsonResponseException(['success' => true, 'data' => $data]); } + + function account_settings_request(string $action, array $data = []): array { + $callback = $GLOBALS['aksrt_account_actions'][$action] ?? null; + if (!is_callable($callback)) throw new \RuntimeException('Missing AJAX action: ' . $action); + $_POST = array_merge(['_settings_nonce' => 'valid'], $data); + try { + $callback(); + } catch (JsonResponseException $response) { + return $response->payload; + } + throw new \RuntimeException('AJAX action returned without a JSON response: ' . $action); + } + + function account_settings_check(bool $condition, string $message): void { + if (!$condition) throw new \RuntimeException($message); + } + + function account_settings_code_key(string $scene, string $target): string { + return 'aksrt_account_code_7_' . $scene . '_' . md5(strtolower(trim($target))); + } + + function account_settings_rate_key(string $scene, string $target): string { + return 'aksrt_account_email_rate_7_' . $scene . '_' . md5(strtolower(trim($target))); + } + + function account_settings_code(string $scene, string $target): string { + $stored = get_transient(account_settings_code_key($scene, $target)); + account_settings_check(is_array($stored), 'expected verification code was not stored for ' . $scene); + return substr((string) ($stored['hash'] ?? ''), strlen('hash:')); + } + + function account_settings_proof_key(): string { + return 'aksrt_account_proof_email_old_7'; + } + + require dirname(__DIR__, 2) . '/inc/modules/user-parts/account-ajax.php'; +} + +namespace AKSRT\Settings { + final class Settings { + public static function get_all(): array { return $GLOBALS['aksrt_account_settings']; } + public static function send_event_email(string $event, string $to, array $values): bool { + $GLOBALS['aksrt_account_mail'][] = ['event' => $event, 'to' => $to, 'values' => $values]; + return true; + } + } +} + +namespace AKSRT\Auth { + final class Service { + public static function validate_password(string $password, string $confirm = ''): true|string { + if (strlen($password) < 6) return 'password too short'; + if ($confirm !== '' && $password !== $confirm) return 'password mismatch'; + return true; + } + } +} + +namespace { + $GLOBALS['aksrt_account_user'] = new WP_User(7, 'old@example.test', 'Test User', 'hash:OldPass123'); + $send_action = 'wp_ajax_aksrt_send_account_email_code'; + $verify_action = 'wp_ajax_aksrt_verify_account_email'; + $change_email_action = 'wp_ajax_aksrt_change_email'; + $change_password_action = 'wp_ajax_aksrt_change_password'; + $saved_user = $GLOBALS['aksrt_account_user']; + $GLOBALS['aksrt_account_user'] = null; + account_settings_check(empty(account_settings_request($send_action, ['scene' => 'verify_email'])['success']), 'email-code request did not handle a missing user object'); + account_settings_check(empty(account_settings_request($verify_action, ['code' => '123456'])['success']), 'old-email verification did not handle a missing user object'); + account_settings_check(empty(account_settings_request($change_email_action, ['new_email' => 'new@example.test'])['success']), 'email update did not handle a missing user object'); + account_settings_check(empty(account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123'])['success']), 'password update did not handle a missing user object'); + $GLOBALS['aksrt_account_user'] = $saved_user; + + $old_send = account_settings_request($send_action, ['scene' => 'verify_email']); + account_settings_check(!empty($old_send['success']), 'old email verification code could not be sent'); + $old_code = account_settings_code('verify_email', 'old@example.test'); + $bad_old_code = account_settings_request($verify_action, ['code' => 'wrong']); + account_settings_check(empty($bad_old_code['success']), 'email verification accepted an incorrect old-email code'); + $old_code_row = get_transient(account_settings_code_key('verify_email', 'old@example.test')); + account_settings_check(($old_code_row['attempts'] ?? 0) === 1, 'incorrect old-email attempt was not recorded'); + $GLOBALS['aksrt_account_transients'][account_settings_code_key('verify_email', 'old@example.test')]['expires'] = time() - 1; + $expired_old_code = account_settings_request($verify_action, ['code' => $old_code]); + account_settings_check(empty($expired_old_code['success']), 'email verification accepted an expired old-email code'); + account_settings_check(get_transient(account_settings_code_key('verify_email', 'old@example.test')) === false, 'expired old-email code was not discarded'); + account_settings_check(!aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'expired old-email code issued an old-email proof'); + delete_transient(account_settings_rate_key('verify_email', 'old@example.test')); + $old_send = account_settings_request($send_action, ['scene' => 'verify_email']); + account_settings_check(!empty($old_send['success']), 'old email code could not be resent after expiry'); + $old_code = account_settings_code('verify_email', 'old@example.test'); + $verified_old = account_settings_request($verify_action, ['code' => $old_code]); + account_settings_check(!empty($verified_old['success']), 'email verification rejected the correct old-email code'); + account_settings_check(aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'old-email proof was not issued'); + account_settings_check(get_transient(account_settings_code_key('verify_email', 'old@example.test')) === false, 'valid old-email code was not consumed'); + + $new_email = 'new@example.test'; + $new_send = account_settings_request($send_action, ['scene' => 'change_email', 'new_email' => $new_email]); + account_settings_check(!empty($new_send['success']), 'new email verification code could not be sent'); + $new_code = account_settings_code('change_email', $new_email); + $GLOBALS['aksrt_account_existing_emails']['taken@example.test'] = 19; + $duplicate = account_settings_request($change_email_action, ['new_email' => 'taken@example.test', 'code' => $new_code, 'agree_terms' => '1']); + account_settings_check(empty($duplicate['success']), 'email update accepted an address already owned by another account'); + account_settings_check(aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'duplicate-email validation consumed the old-email proof'); + account_settings_check(get_transient(account_settings_code_key('change_email', $new_email)) !== false, 'duplicate-email validation consumed the new-email code'); + unset($GLOBALS['aksrt_account_existing_emails']['taken@example.test']); + + $bad_new_code = account_settings_request($change_email_action, ['new_email' => $new_email, 'code' => 'wrong', 'agree_terms' => '1']); + account_settings_check(empty($bad_new_code['success']), 'email update accepted an incorrect new-email code'); + account_settings_check(aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'incorrect-code validation consumed the old-email proof'); + account_settings_check(get_transient(account_settings_code_key('change_email', $new_email)) !== false, 'incorrect-code validation consumed the new-email code'); + $GLOBALS['aksrt_account_transients'][account_settings_code_key('change_email', $new_email)]['expires'] = time() - 1; + $expired_new_code = account_settings_request($change_email_action, ['new_email' => $new_email, 'code' => $new_code, 'agree_terms' => '1']); + account_settings_check(empty($expired_new_code['success']), 'email update accepted an expired new-email code'); + account_settings_check(aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'expired new-email code validation consumed the old-email proof'); + account_settings_check(get_transient(account_settings_code_key('change_email', $new_email)) === false, 'expired new-email code was not discarded'); + delete_transient(account_settings_rate_key('change_email', $new_email)); + $new_send = account_settings_request($send_action, ['scene' => 'change_email', 'new_email' => $new_email]); + account_settings_check(!empty($new_send['success']), 'new email code could not be resent after expiry'); + $new_code = account_settings_code('change_email', $new_email); + + $GLOBALS['aksrt_account_update_fail'] = true; + $write_failure = account_settings_request($change_email_action, ['new_email' => $new_email, 'code' => $new_code, 'agree_terms' => '1']); + account_settings_check(empty($write_failure['success']), 'email update reported success after the user write failed'); + account_settings_check(aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'failed database write consumed the old-email proof'); + account_settings_check(get_transient(account_settings_code_key('change_email', $new_email)) !== false, 'failed database write consumed the new-email code'); + $GLOBALS['aksrt_account_update_fail'] = false; + $changed = account_settings_request($change_email_action, ['new_email' => $new_email, 'code' => $new_code, 'agree_terms' => '1']); + account_settings_check(!empty($changed['success']), 'email update rejected valid old and new proofs'); + account_settings_check($GLOBALS['aksrt_account_user']->user_email === $new_email, 'email update did not persist the new address'); + account_settings_check(!aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'successful update retained the old-email proof'); + account_settings_check(get_transient(account_settings_code_key('change_email', $new_email)) === false, 'successful update retained the new-email code'); + account_settings_check((int) get_user_meta(7, 'aksrt_email_verified', true) === 1, 'successful update did not mark the email verified'); + account_settings_check(end($GLOBALS['aksrt_account_mail'])['event'] === 'change_notice', 'successful update did not notify the previous email'); + + $GLOBALS['aksrt_account_settings']['email_set_captch'] = false; + $direct_email = account_settings_request($change_email_action, ['new_email' => 'direct@example.test', 'agree_terms' => '1']); + account_settings_check(!empty($direct_email['success']), 'email update without configured verification still required a code'); + account_settings_check($GLOBALS['aksrt_account_user']->user_email === 'direct@example.test', 'direct email update did not persist'); + + $GLOBALS['aksrt_account_settings']['email_set_captch'] = true; + $GLOBALS['aksrt_account_user']->user_email = ''; + $first_email = 'first@example.test'; + $first_send = account_settings_request($send_action, ['scene' => 'change_email', 'new_email' => $first_email]); + account_settings_check(!empty($first_send['success']), 'first email binding did not send a new-address code'); + $first_code = account_settings_code('change_email', $first_email); + $first_bind = account_settings_request($change_email_action, ['new_email' => $first_email, 'code' => $first_code, 'agree_terms' => '1']); + account_settings_check(!empty($first_bind['success']), 'first email binding incorrectly required old-email verification'); + account_settings_check($GLOBALS['aksrt_account_user']->user_email === $first_email, 'first email binding did not persist'); + + $GLOBALS['aksrt_account_captcha_fail'] = true; + $captcha_rejected = account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123', 'passwordold' => 'OldPass123']); + account_settings_check(empty($captcha_rejected['success']), 'password change bypassed human verification'); + account_settings_check($GLOBALS['aksrt_account_user']->user_pass === 'hash:OldPass123', 'captcha rejection changed the password'); + $GLOBALS['aksrt_account_captcha_fail'] = false; + + for ($attempt = 0; $attempt < 8; $attempt++) { + $wrong_password = account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123', 'passwordold' => 'wrong']); + account_settings_check(empty($wrong_password['success']), 'password change accepted an incorrect old password'); + } + $limited_password = account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123', 'passwordold' => 'OldPass123']); + account_settings_check(empty($limited_password['success']), 'password change did not apply its old-password attempt limit'); + delete_transient('aksrt_account_rate_change_password_7'); + $changed_password = account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123', 'passwordold' => 'OldPass123']); + account_settings_check(!empty($changed_password['success']), 'password change rejected the correct old password'); + account_settings_check($GLOBALS['aksrt_account_user']->user_pass === 'hash:NewPass123', 'password change did not persist the new password'); + account_settings_check(get_transient('aksrt_account_rate_change_password_7') === false, 'successful password change did not reset its error limit'); + + update_user_meta(7, 'oauth_new', 1); + $oauth_password = account_settings_request($change_password_action, ['password' => 'OAuthPass123', 'password2' => 'OAuthPass123']); + account_settings_check(!empty($oauth_password['success']), 'OAuth first-time password setup incorrectly required an old password'); + account_settings_check(get_user_meta(7, 'oauth_new', true) === '', 'OAuth first-time password setup retained the new-account marker'); + + echo "Account settings harness passed\n"; +} diff --git a/tests/widget-system/captcha-business-contract.test.cjs b/tests/widget-system/captcha-business-contract.test.cjs index d8f2f7a3..87e0dcd8 100644 --- a/tests/widget-system/captcha-business-contract.test.cjs +++ b/tests/widget-system/captcha-business-contract.test.cjs @@ -42,16 +42,16 @@ test('password and email account flows verify captcha once per distinct HTTP req return account.slice(start, end === -1 ? account.length : end); }; - const send = methodBody('wp_ajax_aksrt_send_email_code', 'wp_ajax_aksrt_change_password'); + const send = methodBody('wp_ajax_aksrt_send_account_email_code', 'wp_ajax_aksrt_verify_account_email'); const password = methodBody('wp_ajax_aksrt_change_password', 'wp_ajax_aksrt_change_email'); const email = methodBody('wp_ajax_aksrt_change_email'); assert.equal((send.match(/aksrt_require_captcha\(/g) || []).length, 1); - assert.match(send, /aksrt_require_captcha\( \$scene,/); + assert.match(send, /aksrt_require_captcha\(\s*'change_email'/); assert.equal((password.match(/aksrt_require_captcha\(/g) || []).length, 1); - assert.match(password, /aksrt_require_captcha\( 'change_password'/); + assert.match(password, /aksrt_require_captcha\(\s*'change_password'/); assert.equal((email.match(/aksrt_require_captcha\(/g) || []).length, 1); - assert.match(email, /aksrt_require_captcha\( 'change_email'/); + assert.match(email, /aksrt_require_captcha\(\s*'change_email'/); }); test('legacy public SMS-code AJAX surface is removed while SMS service remains', () => { @@ -59,6 +59,5 @@ test('legacy public SMS-code AJAX surface is removed while SMS service remains', assert.doesNotMatch(sms, /aksrt_send_sms_code/); assert.doesNotMatch(sms, /function ajax_send_code\s*\(/); - assert.match(sms, /public function send_code\(string \$phone\): array/); - assert.match(sms, /public function verify_code\(string \$phone, string \$code\): array/); + assert.match(sms, /public function send_code\(string \$phone, string \$code\): array/); }); diff --git a/tests/widget-system/captcha-cloud-contract.test.cjs b/tests/widget-system/captcha-cloud-contract.test.cjs index 9c4d4f20..8762229c 100644 --- a/tests/widget-system/captcha-cloud-contract.test.cjs +++ b/tests/widget-system/captcha-cloud-contract.test.cjs @@ -193,7 +193,7 @@ test('auth and account security flows consume fresh captcha tickets', () => { const center = read('assets/js/routes/user-center.js'); assert.match(center, /runCaptcha\('bind_phone', form\)[\s\S]*\/user\/phone-code/); assert.match(center, /runCaptcha\('bind_phone', form\)[\s\S]*\/user\/phone/); - assert.match(center, /runCaptcha\(scene, fm\)[\s\S]*resetCaptcha\(scene, fm\)/); + assert.match(center, /function postAjaxForm\(form, action, scene, success\)[\s\S]*?runCaptcha\(scene, form\)[\s\S]*?finally\(function \(\) \{ resetCaptcha\(scene, form\); \}\)/); assert.match(center, /\/oauth\/bind-pass/); assert.match(center, /bind_verification_required:\s*'请先完成人机验证后再绑定'/); }); diff --git a/tests/widget-system/frontend-lifecycle-contract.test.cjs b/tests/widget-system/frontend-lifecycle-contract.test.cjs index 206ad08b..ca46cf87 100644 --- a/tests/widget-system/frontend-lifecycle-contract.test.cjs +++ b/tests/widget-system/frontend-lifecycle-contract.test.cjs @@ -104,6 +104,7 @@ test('wallet reward content opens in the shared modal runtime once', () => { const authorBar = read('templates/parts/single-author-bar.php'); const singleActions = read('inc/helpers/aksrt-single-render.php'); const scripts = read('src/Core/ScriptsPart1b.php'); + const scriptsPart1c = read('src/Core/ScriptsPart1c.php'); assert.match(reward, /runtime\.open\(\{/); assert.match(reward, /className: 'aksrt-reward-modal modal-mini full-sm'/); @@ -122,7 +123,8 @@ test('wallet reward content opens in the shared modal runtime once', () => { assert.match(helper, /data-aksrt-reward-submit/); assert.match(authorBar, /aksrt_render_rewards_template_for_user/); assert.match(singleActions, /aksrt_render_rewards_template_for_user/); - assert.match(scripts, /'needs_reward'\s*=>\s*'single-post\/rewards'/); + assert.match(scriptsPart1c, /\$requirements\['needs_reward'\]\s*=\s*\$is_article && !empty\(\$settings\['rewards_enabled'\]\)/); + assert.match(scriptsPart1c, /\$singular_assets\['needs_reward'\][\s\S]*?\$reward_js[\s\S]*?'aksrt-reward'/); assert.doesNotMatch(helper + authorBar + singleActions, /aksrt_render_rewards_modal_for_user|reward-modal__qr/); }); diff --git a/tests/widget-system/post-visual-contract.test.cjs b/tests/widget-system/post-visual-contract.test.cjs index 545a96be..038b7f1b 100644 --- a/tests/widget-system/post-visual-contract.test.cjs +++ b/tests/widget-system/post-visual-contract.test.cjs @@ -490,8 +490,9 @@ test('profile routes load versioned leaf styles and settings dialogs use the sha assert.match(settingsAccount, /template data-aksrt-settings-modal-template="password"/); assert.match(settingsAccount, /template data-aksrt-settings-modal-template="email"/); assert.match(settingsAccount, /template data-aksrt-settings-modal-template="phone"/); - const settingsSubmitButtons = settingsAccount.match(/