509 lines
15 KiB
JavaScript
509 lines
15 KiB
JavaScript
(function (root, factory) {
|
|
// 账号安全模块同时支持浏览器页面和 Node 单元测试。
|
|
if (typeof module === 'object' && module.exports) {
|
|
module.exports = factory(root);
|
|
return;
|
|
}
|
|
|
|
root.SecurityPages = factory(root);
|
|
if (root.document) {
|
|
root.document.addEventListener('DOMContentLoaded', function () {
|
|
root.SecurityPages.init();
|
|
});
|
|
}
|
|
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
|
|
'use strict';
|
|
|
|
var documentRef = root.document;
|
|
var CAPTCHA_OPERATIONS = {
|
|
phone: 'phone-change',
|
|
deactivate: 'account-deactivate'
|
|
};
|
|
var SMS_CODE_COOLDOWN_SECONDS = 60;
|
|
var boundPhone = '';
|
|
|
|
function getCaptchaOperation(formType) {
|
|
return CAPTCHA_OPERATIONS[formType] || '';
|
|
}
|
|
|
|
function hashPassword(value) {
|
|
// 接口文档要求密码提交 32 位 MD5,测试环境可传入 hashFn 替代。
|
|
if (root.hex_md5) return root.hex_md5(value);
|
|
if (root.md5) return root.md5(value);
|
|
return value;
|
|
}
|
|
|
|
function buildPasswordChangeBody(values, hashFn) {
|
|
var source = values || {};
|
|
var hash = hashFn || hashPassword;
|
|
|
|
return {
|
|
oldPassword: hash(String(source.oldPassword || '').trim()),
|
|
newPassword: hash(String(source.newPassword || '').trim())
|
|
};
|
|
}
|
|
|
|
function buildPhoneChangeBody(values) {
|
|
var source = values || {};
|
|
|
|
return {
|
|
phone: String(source.phone || '').trim(),
|
|
smsCode: String(source.smsCode || '').trim()
|
|
};
|
|
}
|
|
|
|
function buildDeactivateBody(values) {
|
|
var source = values || {};
|
|
|
|
return {
|
|
smsCode: String(source.smsCode || '').trim()
|
|
};
|
|
}
|
|
|
|
function isDangerConfirmed(value) {
|
|
return String(value || '').trim() === '确认注销';
|
|
}
|
|
|
|
function isPhone(value) {
|
|
return /^1\d{10}$/.test(String(value || '').trim());
|
|
}
|
|
|
|
function isSmsCode(value) {
|
|
return /^\d{4}$/.test(String(value || '').trim());
|
|
}
|
|
|
|
function getApi() {
|
|
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
|
}
|
|
|
|
function shouldRedirectToLogin(api, error) {
|
|
var status = error && (error.status || error.code);
|
|
|
|
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
|
}
|
|
|
|
function redirectToLogin(api) {
|
|
if (api && api.clearToken) api.clearToken();
|
|
if (!root.location) return;
|
|
if (typeof root.location.replace === 'function') {
|
|
root.location.replace('login.html');
|
|
return;
|
|
}
|
|
|
|
root.location.href = 'login.html';
|
|
}
|
|
|
|
function redirectUnauthorized(api, error) {
|
|
if (!shouldRedirectToLogin(api, error)) return false;
|
|
redirectToLogin(api);
|
|
return true;
|
|
}
|
|
|
|
async function ensureCaptcha(form, formType, subject) {
|
|
// 发送敏感业务短信前,先取得与当前认证动作绑定的验证码票据。
|
|
var captcha = root.CaptchaPages;
|
|
var operationCode = getCaptchaOperation(formType);
|
|
|
|
if (!operationCode) return true;
|
|
if (!captcha || !captcha.ensureToken) {
|
|
showMessage('验证码组件未加载,请刷新后重试');
|
|
return false;
|
|
}
|
|
|
|
return Boolean(await captcha.ensureToken(form, {
|
|
operationCode: operationCode,
|
|
subject: subject || ''
|
|
}));
|
|
}
|
|
|
|
function query(selector, rootNode) {
|
|
return (rootNode || documentRef).querySelector(selector);
|
|
}
|
|
|
|
function queryAll(selector, rootNode) {
|
|
return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector));
|
|
}
|
|
|
|
function showMessage(message) {
|
|
if (root.layui && root.layui.layer) {
|
|
root.layui.layer.msg(message);
|
|
return;
|
|
}
|
|
|
|
root.alert(message);
|
|
}
|
|
|
|
function getFormValues(form) {
|
|
var values = {};
|
|
|
|
queryAll('[name]', form).forEach(function (field) {
|
|
values[field.name] = field.value;
|
|
});
|
|
|
|
return values;
|
|
}
|
|
|
|
function setStatus(form, type, message) {
|
|
var status = query('[data-security-status]', form);
|
|
|
|
if (!status) return;
|
|
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
|
|
root.ProfileUI.setApiState(status, type, message);
|
|
return;
|
|
}
|
|
status.textContent = message || '';
|
|
}
|
|
|
|
function setSubmitting(form, isSubmitting) {
|
|
queryAll('button[type="submit"]', form).forEach(function (button) {
|
|
if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent;
|
|
button.disabled = isSubmitting;
|
|
button.textContent = isSubmitting ? '提交中…' : button.dataset.defaultLabel;
|
|
});
|
|
}
|
|
|
|
function setBoundPhoneStatus(type, message) {
|
|
queryAll('[data-security-bound-phone]').forEach(function (node) {
|
|
if (root.ProfileUI && root.ProfileUI.setApiState && type) {
|
|
root.ProfileUI.setApiState(node, type, message);
|
|
return;
|
|
}
|
|
node.textContent = message || '';
|
|
});
|
|
}
|
|
|
|
function clearCaptchaToken(form) {
|
|
var field = query('input[name="validToken"]', form);
|
|
|
|
if (field) field.value = '';
|
|
}
|
|
|
|
function takeCaptchaToken(form) {
|
|
var field = query('input[name="validToken"]', form);
|
|
var token = field && field.value || '';
|
|
|
|
if (field) field.value = '';
|
|
return token;
|
|
}
|
|
|
|
function startSmsCooldown(button) {
|
|
var originalText;
|
|
var remaining = SMS_CODE_COOLDOWN_SECONDS;
|
|
var interval;
|
|
|
|
if (!button || typeof root.setInterval !== 'function') return;
|
|
if (button.__smsCooldownTimer && typeof root.clearInterval === 'function') {
|
|
root.clearInterval(button.__smsCooldownTimer);
|
|
}
|
|
|
|
originalText = button.getAttribute('data-sms-code-label') || button.textContent.trim();
|
|
button.setAttribute('data-sms-code-label', originalText);
|
|
button.disabled = true;
|
|
button.textContent = remaining + ' 秒后重试';
|
|
interval = root.setInterval(function () {
|
|
remaining -= 1;
|
|
if (remaining > 0) {
|
|
button.textContent = remaining + ' 秒后重试';
|
|
return;
|
|
}
|
|
|
|
if (typeof root.clearInterval === 'function') root.clearInterval(interval);
|
|
button.__smsCooldownTimer = null;
|
|
button.disabled = false;
|
|
button.textContent = originalText;
|
|
}, 1000);
|
|
button.__smsCooldownTimer = interval;
|
|
}
|
|
|
|
function maskPhone(phone) {
|
|
var value = String(phone || '').trim();
|
|
|
|
return isPhone(value) ? value.slice(0, 3) + '****' + value.slice(-4) : '';
|
|
}
|
|
|
|
async function loadBoundPhone() {
|
|
var api = getApi();
|
|
var profile;
|
|
var message;
|
|
|
|
if (!api || !documentRef) return;
|
|
if (redirectUnauthorized(api)) return;
|
|
setBoundPhoneStatus('loading', '正在读取已绑定手机号…');
|
|
|
|
try {
|
|
profile = await api.currentProfile();
|
|
boundPhone = String(profile && profile.phone || '').trim();
|
|
message = isPhone(boundPhone)
|
|
? '验证码将发送至 ' + maskPhone(boundPhone)
|
|
: '未获取到已绑定手机号';
|
|
setBoundPhoneStatus('', message);
|
|
} catch (error) {
|
|
if (redirectUnauthorized(api, error)) return;
|
|
setBoundPhoneStatus('error', '读取已绑定手机号失败,请刷新后重试。');
|
|
showMessage(error.message || '账号资料加载失败');
|
|
}
|
|
}
|
|
|
|
async function submitPassword(form) {
|
|
var api = getApi();
|
|
var values = getFormValues(form);
|
|
|
|
if (redirectUnauthorized(api)) return;
|
|
if (!values.oldPassword || !values.newPassword) {
|
|
setStatus(form, 'error', '请填写当前密码和新密码');
|
|
showMessage('请填写原密码和新密码');
|
|
return;
|
|
}
|
|
if (String(values.newPassword).length < 6) {
|
|
setStatus(form, 'error', '新密码至少需要 6 位');
|
|
showMessage('新密码至少需要 6 位');
|
|
return;
|
|
}
|
|
if (values.newPassword !== values.confirmPassword) {
|
|
setStatus(form, 'error', '两次新密码不一致');
|
|
showMessage('两次新密码不一致');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSubmitting(form, true);
|
|
setStatus(form, 'loading', '正在修改密码…');
|
|
await api.changePassword(buildPasswordChangeBody(values));
|
|
setStatus(form, '', '密码已修改');
|
|
showMessage('密码已修改');
|
|
form.reset();
|
|
} catch (error) {
|
|
if (redirectUnauthorized(api, error)) return;
|
|
setStatus(form, 'error', '修改失败,请稍后重试。');
|
|
showMessage(error.message || '密码修改失败');
|
|
} finally {
|
|
setSubmitting(form, false);
|
|
}
|
|
}
|
|
|
|
async function submitPhone(form) {
|
|
var api = getApi();
|
|
var body = buildPhoneChangeBody(getFormValues(form));
|
|
|
|
if (redirectUnauthorized(api)) return;
|
|
if (!body.phone || !body.smsCode) {
|
|
setStatus(form, 'error', '请填写新手机号和验证码');
|
|
showMessage('请填写新手机号和验证码');
|
|
return;
|
|
}
|
|
if (!isPhone(body.phone)) {
|
|
setStatus(form, 'error', '请输入正确的新手机号');
|
|
showMessage('请输入正确的新手机号');
|
|
return;
|
|
}
|
|
if (!isSmsCode(body.smsCode)) {
|
|
setStatus(form, 'error', '请输入正确的短信验证码');
|
|
showMessage('请输入正确的短信验证码');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSubmitting(form, true);
|
|
setStatus(form, 'loading', '正在换绑手机号…');
|
|
await api.changePhone(body);
|
|
boundPhone = body.phone;
|
|
setBoundPhoneStatus('', '验证码将发送至 ' + maskPhone(boundPhone));
|
|
setStatus(form, '', '手机号已换绑');
|
|
showMessage('手机号已换绑');
|
|
form.reset();
|
|
} catch (error) {
|
|
if (redirectUnauthorized(api, error)) return;
|
|
setStatus(form, 'error', '换绑失败,请稍后重试。');
|
|
showMessage(error.message || '手机号换绑失败');
|
|
} finally {
|
|
setSubmitting(form, false);
|
|
}
|
|
}
|
|
|
|
async function sendPhoneCode(button) {
|
|
// 换绑手机号使用独立认证动作,避免复用登录或找回密码票据。
|
|
var api = getApi();
|
|
var form = query('[data-security-form="phone"]');
|
|
var values;
|
|
|
|
if (!form || redirectUnauthorized(api)) return;
|
|
values = getFormValues(form);
|
|
if (!isPhone(values.phone)) {
|
|
setStatus(form, 'error', '请输入正确的新手机号');
|
|
showMessage('请输入正确的新手机号');
|
|
return;
|
|
}
|
|
if (!await ensureCaptcha(form, 'phone', values.phone)) return;
|
|
values = getFormValues(form);
|
|
values.validToken = takeCaptchaToken(form);
|
|
button.disabled = true;
|
|
setStatus(form, 'loading', '正在发送验证码…');
|
|
|
|
try {
|
|
await api.sendSmsCode(getCaptchaOperation('phone'), {
|
|
phone: values.phone,
|
|
validToken: values.validToken || ''
|
|
});
|
|
setStatus(form, '', '验证码已发送,请查收短信。');
|
|
showMessage('验证码已发送');
|
|
startSmsCooldown(button);
|
|
} catch (error) {
|
|
if (redirectUnauthorized(api, error)) return;
|
|
setStatus(form, 'error', '验证码发送失败,请稍后重试。');
|
|
showMessage(error.message || '验证码发送失败');
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function sendDeactivateCode(button) {
|
|
var api = getApi();
|
|
var form = query('[data-security-form="deactivate"]');
|
|
var values;
|
|
|
|
if (!form || redirectUnauthorized(api)) return;
|
|
if (!isPhone(boundPhone)) {
|
|
setStatus(form, 'error', '未获取到已绑定手机号,请刷新后重试。');
|
|
showMessage('未获取到已绑定手机号,请刷新后重试');
|
|
return;
|
|
}
|
|
if (!await ensureCaptcha(form, 'deactivate', boundPhone)) return;
|
|
values = getFormValues(form);
|
|
values.validToken = takeCaptchaToken(form);
|
|
button.disabled = true;
|
|
setStatus(form, 'loading', '正在发送验证码…');
|
|
|
|
try {
|
|
await api.sendSmsCode(getCaptchaOperation('deactivate'), {
|
|
phone: boundPhone,
|
|
validToken: values.validToken || ''
|
|
});
|
|
setStatus(form, '', '验证码已发送,请查收短信。');
|
|
showMessage('验证码已发送');
|
|
startSmsCooldown(button);
|
|
} catch (error) {
|
|
if (redirectUnauthorized(api, error)) return;
|
|
setStatus(form, 'error', '验证码发送失败,请稍后重试。');
|
|
showMessage(error.message || '验证码发送失败');
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function submitDeactivate(form) {
|
|
var api = getApi();
|
|
var values = getFormValues(form);
|
|
|
|
if (redirectUnauthorized(api)) return;
|
|
if (!isSmsCode(values.smsCode)) {
|
|
setStatus(form, 'error', '请输入正确的短信验证码');
|
|
showMessage('请输入正确的短信验证码');
|
|
return;
|
|
}
|
|
if (!isDangerConfirmed(values.confirmText)) {
|
|
setStatus(form, 'error', '请完整输入“确认注销”');
|
|
showMessage('请完整输入“确认注销”');
|
|
return;
|
|
}
|
|
if (root.confirm && !root.confirm('账号注销后将退出登录,确认继续?')) return;
|
|
|
|
try {
|
|
setSubmitting(form, true);
|
|
setStatus(form, 'loading', '正在提交注销申请…');
|
|
await api.deactivateAccount(buildDeactivateBody(values));
|
|
showMessage('账号已提交注销');
|
|
root.location.href = 'index.html';
|
|
} catch (error) {
|
|
if (redirectUnauthorized(api, error)) return;
|
|
setStatus(form, 'error', '注销失败,请稍后重试。');
|
|
showMessage(error.message || '账号注销失败');
|
|
} finally {
|
|
setSubmitting(form, false);
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
var api = getApi();
|
|
|
|
if (root.confirm && !root.confirm('确认退出当前账号?')) return;
|
|
|
|
try {
|
|
if (api) await api.logout();
|
|
} catch (error) {
|
|
// 退出请求失败时客户端仍会清理本地令牌,页面必须离开受保护区域。
|
|
} finally {
|
|
root.location.href = 'index.html';
|
|
}
|
|
}
|
|
|
|
function bindActions() {
|
|
if (!documentRef) return;
|
|
|
|
documentRef.addEventListener('submit', function (event) {
|
|
var form = event.target.closest('[data-security-form]');
|
|
var formType;
|
|
|
|
if (!form) return;
|
|
event.preventDefault();
|
|
if (!form.checkValidity()) {
|
|
setStatus(form, 'error', '请检查表单填写是否正确');
|
|
form.reportValidity();
|
|
return;
|
|
}
|
|
formType = form.getAttribute('data-security-form');
|
|
|
|
if (formType === 'password') submitPassword(form);
|
|
if (formType === 'phone') submitPhone(form);
|
|
if (formType === 'deactivate') submitDeactivate(form);
|
|
});
|
|
|
|
documentRef.addEventListener('click', function (event) {
|
|
var codeButton = event.target.closest('[data-security-send-code]');
|
|
var deactivateCodeButton = event.target.closest('[data-security-send-deactivate-code]');
|
|
var button = event.target.closest('[data-security-logout]');
|
|
|
|
if (codeButton) {
|
|
event.preventDefault();
|
|
sendPhoneCode(codeButton);
|
|
return;
|
|
}
|
|
if (deactivateCodeButton) {
|
|
event.preventDefault();
|
|
sendDeactivateCode(deactivateCodeButton);
|
|
return;
|
|
}
|
|
if (!button) return;
|
|
event.preventDefault();
|
|
logout();
|
|
});
|
|
}
|
|
|
|
function bindCaptchaTokenInvalidation() {
|
|
queryAll('[data-security-form] input[name="phone"]').forEach(function (field) {
|
|
field.addEventListener('input', function () {
|
|
clearCaptchaToken(field.closest('[data-security-form]'));
|
|
});
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
if (!documentRef || !query('[data-security-page]')) return;
|
|
if (redirectUnauthorized(getApi())) return;
|
|
|
|
bindActions();
|
|
bindCaptchaTokenInvalidation();
|
|
loadBoundPhone();
|
|
}
|
|
|
|
return {
|
|
buildPasswordChangeBody: buildPasswordChangeBody,
|
|
buildPhoneChangeBody: buildPhoneChangeBody,
|
|
buildDeactivateBody: buildDeactivateBody,
|
|
getCaptchaOperation: getCaptchaOperation,
|
|
isDangerConfirmed: isDangerConfirmed,
|
|
isPhone: isPhone,
|
|
isSmsCode: isSmsCode,
|
|
takeCaptchaToken: takeCaptchaToken,
|
|
shouldRedirectToLogin: shouldRedirectToLogin,
|
|
init: init
|
|
};
|
|
});
|