完成部分短信发送界面

main
落雨楓 1 week ago
parent c59c9c906d
commit 3a7b15dd34

@ -1,41 +1,36 @@
<#import "template.ftl" as layout>
<#import "field.ftl" as field>
<#import "buttons.ftl" as buttons>
<#import "tabs.ftl" as tabs>
<#import "social-providers.ftl" as identityProviders>
<#import "passkeys.ftl" as passkeys>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
<!-- template: login.ftl -->
<script type="text/javascript">
// Add styles
function loadCSS(href) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
loadCSS('${url.resourcesPath}/css/loginPhoneOrPassword.css');
loadCSS('${url.resourcesPath}/css/lib/tom-select.css');
</script>
<#if section = "header">
${msg("loginAccountTitle")}
<#elseif section = "form">
<script type="text/javascript">
// Add styles
function loadCSS(href) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
loadCSS('${url.resourcesPath}/css/loginPhoneOrPassword.css');
loadCSS('${url.resourcesPath}/css/lib/tom-select.css');
</script>
<div id="kc-form">
<div id="kc-form-wrapper">
<#if realm.password>
<form id="kc-form-login" class="${properties.kcFormClass!}" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post" novalidate="novalidate">
<div class="nav pf-v5-u-display-flex" role="tablist">
<button type="button" class="${properties.kcButtonSecondaryClass!} pf-m-block" role="tab" data-active-class="${properties.kcButtonPrimaryClass}" data-toggle="tab" data-login-type="password">
${msg("loginByUsername")}
</button>
<button type="button" class="${properties.kcButtonSecondaryClass!} pf-m-block" role="tab" data-active-class="${properties.kcButtonPrimaryClass}" data-toggle="tab" data-login-type="phone">
${msg("loginByPhone")}
</button>
</div>
<input type="hidden" name="loginType" id="loginType" value="password">
<div class="tab-content">
<div id="kc-login-password" role="tabpanel" class="tab-pane active fade in">
<@tabs.tabsBar panelId="login-panel" filled=true>
<@tabs.tabButton tabId="password" panelId="login-panel" label="loginByUsername" active=true />
<@tabs.tabButton tabId="phone" panelId="login-panel" label="loginByPhone" />
</@tabs.tabsBar>
<@tabs.tabPanel panelId="login-panel">
<@tabs.tabContent tabId="password" active=true>
<#if !usernameHidden??>
<#assign label>
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
@ -46,9 +41,9 @@
<#else>
<@field.password name="password" label=msg("password") forgotPassword=realm.resetPasswordAllowed autofocus=usernameHidden?? autocomplete="current-password" />
</#if>
</div>
</@tabs.tabContent>
<div id="kc-login-phone" role="tabpanel" class="tab-pane fade">
<@tabs.tabContent tabId="phone">
<@field.group name="phoneNumber" label=msg("phoneNumber") error="" required=false>
<div class="${properties.kcInputGroup!}">
<div class="${properties.kcInputGroupItemClass!}">
@ -80,8 +75,8 @@
</span>
</div>
</@field.group>
</div>
</div>
</@tabs.tabContent>
</@tabs.tabPanel>
<#if realm.rememberMe && !usernameHidden??>
<@field.checkbox name="rememberMe" label=msg("rememberMe") value=auth.rememberMe?? />
@ -94,6 +89,29 @@
</div>
</div>
<@passkeys.conditionalUIData />
<script type="application/json" data-configs>
{
"realm": "${realm.name!''}",
"realmUrl": "/realms/${realm.name!''}"
}
</script>
<script type="application/json" data-messages>
{
"error": "错误",
"captcha-load-error": "无法加载验证码,请刷新重试",
"captcha-code-api-error": "无法访问Api",
"countryNameLangCode": "zh-cn",
"sending": "正在发送...",
"sendVerificationCode": "发送验证码",
"resendVerificationCode": "重新发送",
"second": "秒",
"phoneNumberIsEmpty": "请填写手机号码",
"cannotGetConfig": "无法读取配置文件",
"sendVerificationError": "发送短信验证码出错: {1}",
"loading": "加载中..."
}
</script>
<script type="module" src="${url.resourcesPath}/js/loginPhoneOrPassword.js"></script>
<#elseif section = "socialProviders" >
<#if realm.password && social.providers?? && social.providers?has_content>
<@identityProviders.show social=social/>
@ -107,7 +125,4 @@
</div>
</#if>
</#if>
<script type="module" src="${url.resourcesPath}/js/loginPhoneOrPassword.js"></script>
</@layout.registrationLayout>

@ -0,0 +1,98 @@
class Message {
constructor(messageList) {
this.messages = new Map();
this.setMessages(messageList);
}
setMessages(messageList) {
if (messageList instanceof Map) {
this.messages = messageList;
} else {
this.messages.clear();
for (let key in messageList) {
this.messages.set(key, messageList[key]);
}
}
}
localize(msgId, args) {
if (this.messages.has(msgId)) {
return this.replaceVariables(this.messages.get(msgId) || '{' + msgId + '}', args);
} else {
return '{' + msgId + '}';
}
}
// Alias for compatibility
text(msgId, args) {
return this.localize(msgId, args);
}
replaceVariables(template, params = []) {
let result = template;
let paramId;
result = result.replace(/(?<!\\)\$\{(\w+)\}/g, (...matches) => {
paramId = parseInt(matches[1]) - 1;
console.log(paramId);
if (params[paramId]) {
return params[paramId];
} else {
return '';
}
});
return result;
}
static makeGlobal(messageList = []) {
window.msg = new Message(messageList);
}
}
Message.makeGlobal();
class Config {
constructor(configList) {
this.configs = new Map();
this.setConfigs(configList);
}
setConfigs(configList) {
if (configList instanceof Map) {
this.configs = configList;
} else {
this.configs.clear();
for (let key in configList) {
this.configs.set(key, configList[key]);
}
}
}
get(configId, defaultValue = null) {
if (this.configs.has(configId)) {
return this.configs.get(configId)
} else {
return defaultValue;
}
}
static makeGlobal(configList = []) {
window.config = new Config(configList);
}
}
Config.makeGlobal();
document.addEventListener('DOMContentLoaded', function () {
const messages = document.querySelector('script[type="application/json"][data-messages]');
if (messages) {
const messageList = JSON.parse(messages.textContent);
window.msg.setMessages(messageList);
}
const configs = document.querySelector('script[type="application/json"][data-configs]');
if (configs) {
const configList = JSON.parse(configs.textContent);
window.config.setConfigs(configList);
}
});

@ -0,0 +1,64 @@
export class PhoneProviderGeetestCaptcha {
constructor() {
this._captchaObj = null;
this._shouldCallCaptcha = false;
this._onSuccess = null;
this._onError = null;
this.realmUrl = config.get('realmUrl');
fetch(this.realmUrl + '/geetest/code').then((res) => {
if (res.ok) {
return res.json();
} else {
throw msg.text('captcha-code-api-error');
}
}).then((data) => {
initGeetest({
gt: data.gt,
challenge: data.challenge,
offline: !data.success,
new_captcha: true,
product: 'bind',
}, (obj) => {
this._captchaObj = obj;
if (this._shouldCallCaptcha) { //加载完成后立刻调用
this._captchaObj.verify();
this._captchaObj.onSuccess(this.buildOnSuccess(this._onSuccess));
this._shouldCallCaptcha = false;
this._onSuccess = null;
this._onError = null;
}
})
});
}
verify() {
return new Promise((resolve, reject) => {
if (!this._captchaObj) {
this._shouldCallCaptcha = true;
this._onSuccess = resolve;
this._onError = reject;
} else { //已经加载成功的情况,直接调用
this._captchaObj.verify();
this._captchaObj.onSuccess(this.buildOnSuccess(resolve));
this._captchaObj.onError((err) => {
alert(msg.text('captcha-load-error'));
reject('error', err);
});
this._captchaObj.onClose(() => {
reject('close');
});
}
});
}
buildOnSuccess(callback) {
return (function() {
var result = this._captchaObj.getValidate();
callback(result);
this._captchaObj.reset();
}).bind(this);
}
}

@ -0,0 +1,127 @@
const _pfui = {
fadeOutElement: ($el, duration = 150, useHidden = false) => {
return new Promise(resolve => {
$el.style.transition = `opacity ${duration}ms linear`;
$el.style.opacity = '0';
setTimeout(() => {
if (useHidden) {
$el.hidden = true;
} else {
$el.style.display = 'none';
}
resolve();
}, duration);
});
},
fadeInElement: ($el, duration = 150, useHidden = false, display = 'block') => {
return new Promise(resolve => {
if (useHidden) {
$el.hidden = false;
} else {
$el.style.display = display;
}
$el.style.opacity = '0';
$el.style.transition = `opacity ${duration}ms`;
requestAnimationFrame(() => {
$el.style.opacity = '1';
});
setTimeout(() => {
resolve();
}, duration);
});
},
compControllers: [],
init: () => {
// Initialize components if any
document.querySelectorAll('.pf-v5-c-tabs').forEach($tabsBar => {
if (!_pfui.compControllers.find(c => c.$el === $tabsBar)) {
const tabs = new Tabs($tabsBar);
_pfui.compControllers.push({ $el: $tabsBar, controller: tabs });
}
});
}
};
class Tabs {
constructor($tabsBar) {
this.$tabsBar = $tabsBar;
this._handleTabClickEvent = this._handleTabClick.bind(this);
// Find the associated tab panel
if (this.$tabsBar && this.$tabsBar.dataset.tabPanel) {
this.$tabPanel = document.getElementById(this.$tabsBar.dataset.tabPanel);
if (this.$tabPanel) {
this._init();
}
}
}
_init() {
this.$tabsBar.querySelectorAll('.pf-v5-c-tabs__link').forEach($tab => {
$tab.addEventListener('click', this._handleTabClickEvent);
});
}
destroy() {
this.$tabsBar.querySelectorAll('.pf-v5-c-tabs__link').forEach($tab => {
$tab.removeEventListener('click', this._handleTabClickEvent);
});
}
_handleTabClick(e) {
e.preventDefault();
const $tab = e.currentTarget;
this._activateTab($tab);
}
_activateTab($tab) {
const targetId = $tab.dataset.tabTarget;
if (!targetId) {
return;
}
// Deactivate all tabs
this.$tabsBar.querySelectorAll('.pf-v5-c-tabs__item').forEach($item => {
$item.classList.remove('pf-m-current');
const $link = $item.querySelector('.pf-v5-c-tabs__link');
if ($link) {
$link.setAttribute('aria-selected', 'false');
}
});
// Activate the clicked tab
const $tabItem = $tab.closest('.pf-v5-c-tabs__item');
if ($tabItem) {
$tabItem.classList.add('pf-m-current');
}
$tab.setAttribute('aria-selected', 'true');
// Hide all tab panels
let promises = [];
this.$tabPanel.querySelectorAll('.pf-v5-c-tab-content').forEach($panel => {
if (!$panel.hidden) {
promises.push(_pfui.fadeOutElement($panel, 200, true));
}
});
// Show the corresponding tab panel
Promise.all(promises).then(() => {
const $targetPanel = this.$tabPanel.querySelector(`.pf-v5-c-tab-content[data-tab-id="${targetId}"]`);
if ($targetPanel) {
_pfui.fadeInElement($targetPanel, 200, true);
}
});
// Send pf.tab.activate event
const activateEvent = new CustomEvent('pf.tab.activate', {
detail: {
tabId: targetId
}
});
this.$tabsBar.dispatchEvent(activateEvent);
}
}
document.addEventListener('DOMContentLoaded', () => {
_pfui.init();
});

@ -1,14 +1,226 @@
import './lib/gt.js';
import './lib/tom-select.complete.min.js';
import { PhoneProviderGeetestCaptcha } from './lib/gtutils.js';
class SmsSenderForm {
constructor(type, formSelector) {
this.type = type;
this.formElem = document.querySelector(formSelector);
this.realmUrl = config.get('realmUrl');
this.selectAreaCode = document.querySelector("#areaCodeInput");
this.inputPhoneNumber = document.querySelector("#phoneNumberInput");
this.inputCode = document.querySelector("#smsCodeInput");
this.btnSend = document.querySelector("#btnSendSmsCode");
this.countdown = null;
this.resendTimeout = 120;
this.areaCodeSelect = null;
this.bindEvents();
this.initConfig();
this.initCaptcha();
}
bindEvents() {
this.btnSend.addEventListener('click', this.onBtnSendClick.bind(this));
this.inputPhoneNumber.addEventListener('input', () => {
// Hide error message when user types
const errorMsg = this.formElem.querySelector('.pf-v5-c-form__helper-text--error');
if (errorMsg) {
errorMsg.style.display = 'none';
}
});
this.formElem.addEventListener('submit', () => {
if (this.areaCodeSelect) {
this.areaCodeSelect.enable();
}
});
}
initCaptcha() {
this.captcha = new PhoneProviderGeetestCaptcha();
}
initConfig() {
fetch(this.realmUrl + '/sms').then((res) => {
if (res.ok) {
return res.json();
} else {
throw new Error(msg.localize('cannotGetConfig'));
}
}).then((res) => {
this.setAreaCodeList(res.areaCodeList || []);
// Initialize TomSelect after data is loaded
this.areaCodeSelect = new TomSelect('#areaCodeInput', {
maxItems: 1,
valueField: 'value',
labelField: 'text',
searchField: ['text', 'value'],
create: false,
});
if (res.areaLocked) {
this.areaCodeSelect.disable();
}
const defaultAreaCode = this.selectAreaCode.getAttribute('data-value') || res.defaultAreaCode || '86';
this.areaCodeSelect.setValue(defaultAreaCode.toString());
}).catch((err) => {
console.error(err);
this.showError(err.message);
});
}
getFilledAreaCode(areaCode) {
const maxLen = 4;
let areaCodeStr = areaCode.toString();
return '+' + areaCodeStr + ('\u2002'.repeat(maxLen - areaCodeStr.length));
}
getLocalizedName(nameList, langCode) {
if (langCode in nameList) {
return nameList[langCode];
} else {
return nameList['en'];
}
}
showError(errorMessage) {
// Find or create error message element
let errorElement = this.formElem.querySelector('.phone-error-message');
if (!errorElement) {
errorElement = document.createElement('div');
errorElement.className = 'pf-v5-c-form__helper-text pf-v5-c-form__helper-text--error phone-error-message';
const phoneGroup = this.formElem.querySelector('[name="phoneNumber"]').closest('.pf-v5-c-form__group');
if (phoneGroup) {
phoneGroup.appendChild(errorElement);
}
}
errorElement.textContent = errorMessage;
errorElement.style.display = 'block';
}
setAreaCodeList(areaCodeList) {
const langCode = msg.localize('countryNameLangCode');
// Clear existing options
this.selectAreaCode.innerHTML = '';
// Add new options
areaCodeList.forEach((info) => {
const option = document.createElement('option');
option.value = info.areaCode;
const displayName = this.getFilledAreaCode(info.areaCode) + '\u2002\u2002' + this.getLocalizedName(info.name, langCode);
option.textContent = displayName;
this.selectAreaCode.appendChild(option);
});
}
startCountdown() {
let currentTimeout = this.resendTimeout;
const resendMsg = msg.localize('resendVerificationCode');
const secondMsg = msg.localize('second');
this.countdown = setInterval(() => {
currentTimeout -= 1;
if (currentTimeout <= 0) {
this.stopCountdown();
return;
}
this.btnSend.textContent = resendMsg + ' (' + currentTimeout.toString() + secondMsg + ')';
}, 1000);
}
stopCountdown() {
if (this.countdown !== null) {
clearInterval(this.countdown);
this.countdown = null;
this.btnSend.textContent = msg.localize('resendVerificationCode');
this.btnSend.disabled = false;
}
}
checkInput() {
if (this.inputPhoneNumber.value.trim() === "") {
this.showError(msg.localize('phoneNumberIsEmpty'));
return false;
}
return true;
}
onBtnSendClick() {
if (!this.checkInput()) return;
this.captcha.verify().then((data) => {
this.btnSend.disabled = true;
this.btnSend.textContent = msg.localize('sending');
data.areaCode = this.areaCodeSelect.getValue();
data.phoneNumber = this.inputPhoneNumber.value;
fetch(this.realmUrl + '/sms/' + this.type + '-code', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
}).then((res) => {
if (res.ok) {
return res.json();
} else {
throw new Error(msg.localize('sendVerificationError', [res.status + ' ' + res.statusText]));
}
}).then((res) => {
if (res.status == 1) {
this.startCountdown();
} else if (res.status == 0) {
throw new Error(res.errormsg ? msg.localize(res.errormsg, [res.error]) : res.error);
}
}).catch((err) => {
console.error(err);
this.showError(err.message);
this.btnSend.disabled = false;
this.btnSend.textContent = msg.localize('sendVerificationCode');
});
}).catch((err) => {
if (err !== 'close') { // User closed captcha
console.error(err);
this.showError(typeof err === 'string' ? err : err.message);
this.btnSend.disabled = false;
this.btnSend.textContent = msg.localize('sendVerificationCode');
}
});
}
}
document.addEventListener('DOMContentLoaded', function () {
// Initialize TomSelect for area code selection
const areaCodeSelect = new TomSelect('#areaCodeInput', {
maxItems: 1,
valueField: 'areaCode',
labelField: 'countryName',
searchField: ['countryName', 'countryCode', 'areaCode'],
create: false,
// Initialize SMS sender form for login
window.SmsSenderForm = new SmsSenderForm("login", "#kc-form-login");
// Handle tab switching to set the login type
document.addEventListener('pf.tab.activate', function(event) {
const tabId = event.detail.tabId;
let loginTypeInput = document.querySelector('#loginType');
if (!loginTypeInput) {
loginTypeInput = document.createElement('input');
loginTypeInput.type = 'hidden';
loginTypeInput.name = 'loginType';
loginTypeInput.id = 'loginType';
document.querySelector('#kc-form-login').appendChild(loginTypeInput);
}
loginTypeInput.value = tabId; // 'password' or 'phone'
});
// Set initial login type to password (default active tab)
const initialLoginType = document.createElement('input');
initialLoginType.type = 'hidden';
initialLoginType.name = 'loginType';
initialLoginType.id = 'loginType';
initialLoginType.value = 'password';
document.querySelector('#kc-form-login').appendChild(initialLoginType);
});

@ -0,0 +1,77 @@
<#macro tabsBar panelId filled=false>
<div
class="pf-v5-c-tabs<#if filled> pf-m-fill</#if>"
role="region"
id="default-tabs"
<#if panelId??>data-tab-panel="${panelId}"</#if>
>
<button
class="pf-v5-c-tabs__scroll-button"
type="button"
disabled
aria-hidden="true"
aria-label="Scroll left"
>
<i class="fas fa-angle-left" aria-hidden="true">
</i>
</button>
<ul class="pf-v5-c-tabs__list" role="tablist">
<#nested>
</ul>
<button
class="pf-v5-c-tabs__scroll-button"
type="button"
disabled
aria-hidden="true"
aria-label="Scroll right"
>
<i class="fas fa-angle-right" aria-hidden="true">
</i>
</button>
</div>
</#macro>
<#macro tabButton tabId label panelId="" active=false disabled=false>
<li class="pf-v5-c-tabs__item<#if active> pf-m-current</#if>" role="presentation">
<a
class="pf-v5-c-tabs__link"
<#if tabId??>
href="#${tabId}"
data-tab-target="${tabId}"
</#if>
<#if panelId??>
aria-controls="${panelId}"
</#if>
role="tab"
aria-selected="<#if active>true<#else>false</#if>"
<#if disabled>
aria-disabled="true"
disabled
</#if>
>
${kcSanitize(msg(label))?no_esc}
</a>
</li>
</#macro>
<#macro tabPanel panelId>
<div
class="pf-v5-c-tab-panel"
id="${panelId}"
>
<#nested>
</div>
</#macro>
<#macro tabContent tabId active=false>
<div
data-tab-id="${tabId}"
role="tabpanel"
class="pf-v5-c-tab-content"
<#if !active>
hidden
</#if>
>
<#nested>
</div>
</#macro>

@ -4,6 +4,8 @@ import=common/keycloak
styles=css/styles.css
stylesCommon=vendor/patternfly-v5/patternfly.min.css vendor/patternfly-v5/patternfly-addons.css
scripts=js/lib/patternfly-simple.js js/lib/common.js
darkMode=true
kcFormGroupClass=pf-v5-c-form__group

Loading…
Cancel
Save