diff --git a/src/gui/src/UI/Components/CodeEntryView.js b/src/gui/src/UI/Components/CodeEntryView.js deleted file mode 100644 index 75d73c625..000000000 --- a/src/gui/src/UI/Components/CodeEntryView.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -const Component = use('util.Component'); - -export default def(class CodeEntryView extends Component { - static ID = 'ui.component.CodeEntryView'; - - static PROPERTIES = { - value: {}, - error: {}, - is_checking_code: {}, - }; - - static RENDER_MODE = Component.NO_SHADOW; - - static CSS = /*css*/` - .wrapper { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - color: #3e5362; - } - - fieldset[name=number-code] { - display: flex; - justify-content: space-between; - gap: 5px; - } - - .digit-input { - box-sizing: border-box; - flex-grow: 1; - height: 50px; - font-size: 25px; - text-align: center; - border-radius: 0.5rem; - -moz-appearance: textfield; - border: 2px solid #9b9b9b; - color: #485660; - } - - .digit-input::-webkit-outer-spin-button, - .digit-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - - .confirm-code-hyphen { - display: inline-block; - flex-grow: 2; - text-align: center; - font-size: 40px; - font-weight: 300; - } - `; - - create_template ({ template }) { - // TODO: static member for strings - const submit_btn_txt = i18n('confirm_code_generic_submit'); - - $(template).html(/*html*/` -
-
-
-
- - - - - - - - -
- -
-
- `); - } - - on_focus () { - $(this.dom_).find('.digit-input').first().focus(); - } - - on_ready ({ listen }) { - listen('error', (error) => { - if ( ! error ) return $(this.dom_).find('.error').hide(); - $(this.dom_).find('.error').text(error).show(); - }); - - listen('value', value => { - // clear the inputs - if ( value === undefined ) { - $(this.dom_).find('.digit-input').val(''); - return; - } - }); - - listen('is_checking_code', (is_checking_code, { old_value }) => { - if ( old_value === is_checking_code ) return; - if ( old_value === undefined ) return; - - const $button = $(this.dom_).find('.code-confirm-btn'); - - if ( is_checking_code ) { - // set animation - $button.prop('disabled', true); - $button.html('circle anim'); - return; - } - - const submit_btn_txt = i18n('confirm_code_generic_try_again'); - $button.html(submit_btn_txt); - $button.prop('disabled', false); - }); - - const that = this; - $(this.dom_).find('.code-confirm-btn').on('click submit', function (e) { - e.preventDefault(); - e.stopPropagation(); - - const $button = $(this); - - $button.prop('disabled', true); - $button.closest('.error').hide(); - - that.set('is_checking_code', true); - - // force update to trigger the listener - that.set('value', that.get('value')); - }); - - // Elements - const numberCodeForm = this.dom_.querySelector('[data-number-code-form]'); - const numberCodeInputs = [...numberCodeForm.querySelectorAll('[data-number-code-input]')]; - - // Event listeners - numberCodeForm.addEventListener('input', ({ target }) => { - const inputLength = target.value.length || 0; - let currentIndex = Number(target.dataset.numberCodeInput); - if ( inputLength === 2 ) { - const inputValues = target.value.split(''); - target.value = inputValues[0]; - } - else if ( inputLength > 1 ) { - const inputValues = target.value.split(''); - - inputValues.forEach((value, valueIndex) => { - const nextValueIndex = currentIndex + valueIndex; - - if ( nextValueIndex >= numberCodeInputs.length ) { - return; - } - - numberCodeInputs[nextValueIndex].value = value; - }); - currentIndex += inputValues.length - 2; - } - - const nextIndex = currentIndex + 1; - - if ( nextIndex < numberCodeInputs.length ) { - numberCodeInputs[nextIndex].focus(); - } - - // Concatenate all inputs into one string to create the final code - let current_code = ''; - for ( let i = 0; i < numberCodeInputs.length; i++ ) { - current_code += numberCodeInputs[i].value; - } - - const submit_btn_txt = i18n('confirm_code_generic_submit'); - $(this.dom_).find('.code-confirm-btn').html(submit_btn_txt); - - // Automatically submit if 6 digits entered - if ( current_code.length === 6 ) { - $(this.dom_).find('.code-confirm-btn').prop('disabled', false); - this.set('value', current_code); - this.set('is_checking_code', true); - } else { - $(this.dom_).find('.code-confirm-btn').prop('disabled', true); - } - }); - - numberCodeForm.addEventListener('keydown', (e) => { - const { code, target } = e; - - const currentIndex = Number(target.dataset.numberCodeInput); - const previousIndex = currentIndex - 1; - const nextIndex = currentIndex + 1; - - const hasPreviousIndex = previousIndex >= 0; - const hasNextIndex = nextIndex <= numberCodeInputs.length - 1; - - switch ( code ) { - case 'ArrowLeft': - case 'ArrowUp': - if ( hasPreviousIndex ) { - numberCodeInputs[previousIndex].focus(); - } - e.preventDefault(); - break; - - case 'ArrowRight': - case 'ArrowDown': - if ( hasNextIndex ) { - numberCodeInputs[nextIndex].focus(); - } - e.preventDefault(); - break; - case 'Backspace': - if ( !e.target.value.length && hasPreviousIndex ) { - numberCodeInputs[previousIndex].value = null; - numberCodeInputs[previousIndex].focus(); - } - break; - default: - break; - } - }); - } -}); diff --git a/src/gui/src/UI/Components/StepView.js b/src/gui/src/UI/Components/StepView.js deleted file mode 100644 index d25faa8f9..000000000 --- a/src/gui/src/UI/Components/StepView.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -const Component = use('util.Component'); - -export default def(class StepView extends Component { - static ID = 'ui.component.StepView'; - - static PROPERTIES = { - children: {}, - done: { value: false }, - position: { value: 0 }, - }; - - static CSS = ` - #wrapper { - display: none; - height: 100%; - } - * { -webkit-font-smoothing: antialiased;} - `; - - create_template ({ template }) { - $(template).html(` -
- -
- `); - } - - on_focus () { - this.children[this.get('position')].focus(); - } - - on_ready ({ listen }) { - for ( const child of this.get('children') ) { - child.setAttribute('slot', 'inside'); - child.attach(this); - $(child).hide(); - } - - // show the first child - $(this.children[0]).show(); - - // listen for changes to the current step - listen('position', position => { - // hide all children - for ( const child of this.children ) { - $(child).hide(); - } - - // show the child at the current position - $(this.children[position]).show(); - this.children[position].focus(); - }); - - // now that we're ready, show the wrapper - $(this.dom_).find('#wrapper').show(); - } - - add_child (child) { - const children = this.get('children'); - let pos = children.length; - child.setAttribute('slot', 'inside'); - $(child).hide(); - child.attach(this); - - return pos; - } - - display (child) { - const pos = this.add_child(child); - this.goto(pos); - } - - back () { - if ( this.get('position') === 0 ) return; - this.set('position', this.get('position') - 1); - } - - next () { - if ( this.get('position') === this.children.length - 1 ) { - this.set('done', true); - return; - } - this.set('position', this.get('position') + 1); - } - - goto (pos) { - this.set('position', pos); - } -}); diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index eca9f6035..ec8da0720 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -18,16 +18,236 @@ */ import TeePromise from '../util/TeePromise.js'; -import Button from './Components/Button.js'; -import CodeEntryView from './Components/CodeEntryView.js'; -import Flexer from './Components/Flexer.js'; -import JustHTML from './Components/JustHTML.js'; -import StepView from './Components/StepView.js'; -import UIComponentWindow from './UIComponentWindow.js'; import UIWindow from './UIWindow.js'; import UIWindowRecoverPassword from './UIWindowRecoverPassword.js'; import UIWindowSignup from './UIWindowSignup.js'; +// ── 2FA Login CSS (injected once) ─────────────────────────────────────────── +const LOGIN_2FA_CSS = ` +.login-2fa { + display: flex; + flex-direction: column; + align-items: center; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: #1a2233; + user-select: none; + -webkit-user-select: none; +} + +.login-2fa-icon { + width: 52px; + height: 52px; + border-radius: 50%; + background: #eff6ff; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + flex-shrink: 0; +} + +/* ── Screens ──────────────────────────────────────────────────────────── */ +.login-2fa-screen { + display: none; + flex-direction: column; + align-items: center; + width: 100%; + animation: login-2fa-fade-in 0.25s ease; +} +.login-2fa-screen.active { + display: flex; +} +@keyframes login-2fa-fade-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.login-2fa-title { + font-size: 18px; + font-weight: 700; + color: #1a2233; + text-align: center; + margin: 0 0 6px; +} +.login-2fa-desc { + font-size: 14px; + line-height: 1.6; + color: #64748b; + text-align: center; + margin: 0 0 24px; + padding: 0; +} + +/* ── OTP code inputs ──────────────────────────────────────────────────── */ +.login-2fa-code-inputs { + display: flex; + gap: 8px; + justify-content: center; + margin-bottom: 8px; + width: 100%; + max-width: 300px; +} +.login-2fa-code-inputs input { + width: 44px; + height: 52px; + text-align: center; + font-size: 22px; + font-weight: 600; + font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; + border: 2px solid #e2e8f0; + border-radius: 10px; + background: #fff; + color: #1a2233; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + caret-color: #3b82f6; + -moz-appearance: textfield; +} +.login-2fa-code-inputs input::-webkit-outer-spin-button, +.login-2fa-code-inputs input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.login-2fa-code-inputs input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} +.login-2fa-code-inputs input.error { + border-color: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); + animation: login-2fa-shake 0.4s ease; +} +@keyframes login-2fa-shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-3px); } + 80% { transform: translateX(2px); } +} + +/* ── Error message ────────────────────────────────────────────────────── */ +.login-2fa-error { + font-size: 13px; + color: #ef4444; + text-align: center; + min-height: 20px; + margin-bottom: 4px; +} + +/* ── Spinner ──────────────────────────────────────────────────────────── */ +.login-2fa-spinner { + display: none; + justify-content: center; + align-items: center; + gap: 8px; + padding: 8px 0; + font-size: 13px; + color: #64748b; +} +.login-2fa-spinner.visible { + display: flex; +} +.login-2fa-spinner-icon { + width: 16px; + height: 16px; + border: 2px solid #e2e8f0; + border-top-color: #3b82f6; + border-radius: 50%; + animation: login-2fa-spin 0.6s linear infinite; +} +@keyframes login-2fa-spin { + to { transform: rotate(360deg); } +} + +/* ── Link button ──────────────────────────────────────────────────────── */ +.login-2fa-link-btn { + background: none; + border: none; + color: #3b82f6; + font-size: 13px; + font-weight: 500; + cursor: pointer; + padding: 8px 0; + margin-top: 8px; + transition: color 0.15s ease; +} +.login-2fa-link-btn:hover { + color: #2563eb; + text-decoration: underline; +} + +/* ── Recovery input ───────────────────────────────────────────────────── */ +.login-2fa-recovery-input { + width: 100%; + max-width: 300px; + box-sizing: border-box; + height: 52px; + font-size: 22px; + font-weight: 600; + text-align: center; + font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; + letter-spacing: 2px; + border: 2px solid #e2e8f0; + border-radius: 10px; + background: #fff; + color: #1a2233; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + caret-color: #3b82f6; +} +.login-2fa-recovery-input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +/* ── Recovery error ───────────────────────────────────────────────────── */ +.login-2fa-recovery-error { + display: none; + font-size: 13px; + color: #ef4444; + text-align: center; + padding: 8px 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + margin-bottom: 12px; + width: 100%; + max-width: 300px; + box-sizing: border-box; +} + +/* ── Responsive ───────────────────────────────────────────────────────── */ +@media (max-width: 420px) { + .login-2fa-code-inputs { + gap: 6px; + } + .login-2fa-code-inputs input { + width: 38px; + height: 46px; + font-size: 19px; + border-radius: 8px; + } + .login-2fa-recovery-input { + height: 46px; + font-size: 19px; + } + .login-2fa-title { + font-size: 16px; + } + .login-2fa-desc { + font-size: 13px; + margin-bottom: 18px; + } +} +`; + +let login_2fa_css_injected = false; +const inject_login_2fa_css = () => { + if ( login_2fa_css_injected ) return; + login_2fa_css_injected = true; + $('