diff --git a/keycloak.v2-dev/login/login-phone-or-password.ftl b/keycloak.v2-dev/login/login-phone-or-password.ftl index 0ce0a92..218aef2 100755 --- a/keycloak.v2-dev/login/login-phone-or-password.ftl +++ b/keycloak.v2-dev/login/login-phone-or-password.ftl @@ -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> - - - <#if section = "header"> ${msg("loginAccountTitle")} <#elseif section = "form"> + +
<#if realm.password>
- - -
-
+ <@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.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")} @@ -46,9 +41,9 @@ <#else> <@field.password name="password" label=msg("password") forgotPassword=realm.resetPasswordAllowed autofocus=usernameHidden?? autocomplete="current-password" /> -
+ -
+ <@tabs.tabContent tabId="phone"> <@field.group name="phoneNumber" label=msg("phoneNumber") error="" required=false>
@@ -80,8 +75,8 @@
-
-
+ + <#if realm.rememberMe && !usernameHidden??> <@field.checkbox name="rememberMe" label=msg("rememberMe") value=auth.rememberMe?? /> @@ -94,6 +89,29 @@
<@passkeys.conditionalUIData /> + + + <#elseif section = "socialProviders" > <#if realm.password && social.providers?? && social.providers?has_content> <@identityProviders.show social=social/> @@ -107,7 +125,4 @@
- - - diff --git a/keycloak.v2-dev/login/resources/js/lib/common.js b/keycloak.v2-dev/login/resources/js/lib/common.js new file mode 100644 index 0000000..46f5fd8 --- /dev/null +++ b/keycloak.v2-dev/login/resources/js/lib/common.js @@ -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(/(? { + 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); + } +}); \ No newline at end of file diff --git a/keycloak.v2-dev/login/resources/js/lib/gtutils.js b/keycloak.v2-dev/login/resources/js/lib/gtutils.js new file mode 100644 index 0000000..2635820 --- /dev/null +++ b/keycloak.v2-dev/login/resources/js/lib/gtutils.js @@ -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); + } +} \ No newline at end of file diff --git a/keycloak.v2-dev/login/resources/js/lib/patternfly-simple.js b/keycloak.v2-dev/login/resources/js/lib/patternfly-simple.js index e69de29..a1fb752 100644 --- a/keycloak.v2-dev/login/resources/js/lib/patternfly-simple.js +++ b/keycloak.v2-dev/login/resources/js/lib/patternfly-simple.js @@ -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(); +}); \ No newline at end of file diff --git a/keycloak.v2-dev/login/resources/js/loginPhoneOrPassword.js b/keycloak.v2-dev/login/resources/js/loginPhoneOrPassword.js index a257080..40d6ddc 100644 --- a/keycloak.v2-dev/login/resources/js/loginPhoneOrPassword.js +++ b/keycloak.v2-dev/login/resources/js/loginPhoneOrPassword.js @@ -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); }); \ No newline at end of file diff --git a/keycloak.v2-dev/login/tabs.ftl b/keycloak.v2-dev/login/tabs.ftl new file mode 100644 index 0000000..fab4fcf --- /dev/null +++ b/keycloak.v2-dev/login/tabs.ftl @@ -0,0 +1,77 @@ +<#macro tabsBar panelId filled=false> +
data-tab-panel="${panelId}" +> + + + +
+ + +<#macro tabButton tabId label panelId="" active=false disabled=false> + + + +<#macro tabPanel panelId> +
+ <#nested> +
+ + +<#macro tabContent tabId active=false> +
+ hidden + +> + <#nested> +
+ diff --git a/keycloak.v2-dev/login/theme.properties b/keycloak.v2-dev/login/theme.properties index 42d41fa..1c79112 100644 --- a/keycloak.v2-dev/login/theme.properties +++ b/keycloak.v2-dev/login/theme.properties @@ -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