mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-29 12:50:59 +00:00
Implement inline 2FA UI, remove old components (#3025)
* Implement inline 2FA UI, remove old components * Re-enable login button on window close
This commit is contained in:
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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*/`
|
||||
<div class="wrapper">
|
||||
<form>
|
||||
<div class="error"></div>
|
||||
<fieldset name="number-code" style="border: none; padding:0;" data-number-code-form>
|
||||
<input class="digit-input" type="number" min='0' max='9' name='number-code-0' data-number-code-input='0' required />
|
||||
<input class="digit-input" type="number" min='0' max='9' name='number-code-1' data-number-code-input='1' required />
|
||||
<input class="digit-input" type="number" min='0' max='9' name='number-code-2' data-number-code-input='2' required />
|
||||
<span class="confirm-code-hyphen">-</span>
|
||||
<input class="digit-input" type="number" min='0' max='9' name='number-code-3' data-number-code-input='3' required />
|
||||
<input class="digit-input" type="number" min='0' max='9' name='number-code-4' data-number-code-input='4' required />
|
||||
<input class="digit-input" type="number" min='0' max='9' name='number-code-5' data-number-code-input='5' required />
|
||||
</fieldset>
|
||||
<button type="submit" class="button button-block button-primary code-confirm-btn" style="margin-top:10px;" disabled>${
|
||||
submit_btn_txt
|
||||
}</button>
|
||||
</form>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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('<svg style="width:20px; margin-top: 5px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#fff" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#eee" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>');
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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(`
|
||||
<div id="wrapper">
|
||||
<slot name="inside"></slot>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
+430
-176
@@ -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;
|
||||
$('<style/>').text(LOGIN_2FA_CSS).appendTo('head');
|
||||
};
|
||||
|
||||
async function UIWindowLogin (options) {
|
||||
options = options ?? {};
|
||||
|
||||
@@ -252,192 +472,226 @@ async function UIWindowLogin (options) {
|
||||
// Keep the button disabled on success since we're redirecting or closing
|
||||
let p = Promise.resolve();
|
||||
if ( data.next_step === 'otp' ) {
|
||||
inject_login_2fa_css();
|
||||
p = new TeePromise();
|
||||
let code_entry;
|
||||
let recovery_entry;
|
||||
let win;
|
||||
let stepper;
|
||||
const otp_option = new Flexer({
|
||||
children: [
|
||||
new JustHTML({
|
||||
html: /*html*/`
|
||||
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
|
||||
i18n('login2fa_otp_title')
|
||||
}</h3>
|
||||
<p style="text-align:center; padding: 0 20px;">${
|
||||
i18n('login2fa_otp_instructions')
|
||||
}</p>
|
||||
`,
|
||||
}),
|
||||
new CodeEntryView({
|
||||
_ref: me => code_entry = me,
|
||||
async 'property.value' (value, { component }) {
|
||||
let error_i18n_key = 'something_went_wrong';
|
||||
if ( ! value ) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.gui_origin}/login/otp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: data.otp_jwt_token,
|
||||
code: value,
|
||||
}),
|
||||
});
|
||||
|
||||
if ( resp.status === 429 ) {
|
||||
error_i18n_key = 'confirm_code_generic_too_many_requests';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
// ── Build 2FA verification HTML ──
|
||||
let h2fa = '';
|
||||
h2fa += '<div class="login-2fa">';
|
||||
|
||||
const next_data = await resp.json();
|
||||
// ── Shield icon ──
|
||||
h2fa += '<div class="login-2fa-icon">';
|
||||
h2fa += '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
||||
h2fa += '</div>';
|
||||
|
||||
if ( ! next_data.proceed ) {
|
||||
error_i18n_key = 'confirm_code_generic_incorrect';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
// ── OTP screen ──
|
||||
h2fa += '<div class="login-2fa-screen active" data-screen="otp">';
|
||||
h2fa += `<h3 class="login-2fa-title">${i18n('login2fa_otp_title')}</h3>`;
|
||||
h2fa += `<p class="login-2fa-desc">${i18n('login2fa_otp_instructions')}</p>`;
|
||||
h2fa += '<div class="login-2fa-code-inputs">';
|
||||
for ( let i = 0; i < 6; i++ ) {
|
||||
h2fa += `<input type="text" inputmode="numeric" maxlength="1" autocomplete="off" data-idx="${i}" />`;
|
||||
}
|
||||
h2fa += '</div>';
|
||||
h2fa += '<div class="login-2fa-error"></div>';
|
||||
h2fa += `<div class="login-2fa-spinner"><div class="login-2fa-spinner-icon"></div><span>${i18n('verifying') || 'Verifying...'}</span></div>`;
|
||||
h2fa += `<button type="button" class="login-2fa-link-btn login-2fa-to-recovery">${i18n('login2fa_use_recovery_code')}</button>`;
|
||||
h2fa += '</div>';
|
||||
|
||||
component.set('is_checking_code', false);
|
||||
// ── Recovery screen ──
|
||||
h2fa += '<div class="login-2fa-screen" data-screen="recovery">';
|
||||
h2fa += `<h3 class="login-2fa-title">${i18n('login2fa_recovery_title')}</h3>`;
|
||||
h2fa += `<p class="login-2fa-desc">${i18n('login2fa_recovery_instructions')}</p>`;
|
||||
h2fa += '<div class="login-2fa-recovery-error"></div>';
|
||||
h2fa += `<input type="text" class="login-2fa-recovery-input" placeholder="${html_encode(i18n('login2fa_recovery_placeholder'))}" maxlength="8" autocomplete="off" />`;
|
||||
h2fa += `<button type="button" class="login-2fa-link-btn login-2fa-to-otp">${i18n('login2fa_recovery_back')}</button>`;
|
||||
h2fa += '</div>';
|
||||
|
||||
data = next_data;
|
||||
h2fa += '</div>';
|
||||
|
||||
$(win).close();
|
||||
p.resolve();
|
||||
} catch (e) {
|
||||
// keeping this log; useful in screenshots
|
||||
component.set('error', i18n(error_i18n_key));
|
||||
component.set('is_checking_code', false);
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Button({
|
||||
label: i18n('login2fa_use_recovery_code'),
|
||||
style: 'link',
|
||||
on_click: async () => {
|
||||
stepper.next();
|
||||
code_entry.set('value', undefined);
|
||||
code_entry.set('error', undefined);
|
||||
},
|
||||
}),
|
||||
],
|
||||
'event.focus' () {
|
||||
code_entry.focus();
|
||||
},
|
||||
});
|
||||
const recovery_option = new Flexer({
|
||||
children: [
|
||||
new JustHTML({
|
||||
html: /*html*/`
|
||||
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
|
||||
i18n('login2fa_recovery_title')
|
||||
}</h3>
|
||||
<p style="text-align:center; padding: 0 20px;">${
|
||||
i18n('login2fa_recovery_instructions')
|
||||
}</p>
|
||||
`,
|
||||
}),
|
||||
new JustHTML({
|
||||
_ref: me => {
|
||||
recovery_entry = me;
|
||||
const set_error = (msg) => {
|
||||
const err = me.dom_.querySelector('.error');
|
||||
if ( ! err ) return;
|
||||
if ( ! msg ) {
|
||||
err.style.display = 'none';
|
||||
err.textContent = '';
|
||||
} else {
|
||||
err.textContent = msg;
|
||||
err.style.display = 'block';
|
||||
}
|
||||
};
|
||||
me.clear_input = () => {
|
||||
const input = me.dom_.querySelector('.recovery-code-input');
|
||||
if ( input ) input.value = '';
|
||||
};
|
||||
me.clear_error = () => set_error(undefined);
|
||||
me.dom_.addEventListener('input', async (e) => {
|
||||
if ( ! e.target.matches('.recovery-code-input') ) return;
|
||||
const value = e.target.value;
|
||||
if ( value.length !== 8 ) return;
|
||||
let error_i18n_key = 'something_went_wrong';
|
||||
try {
|
||||
const resp = await fetch(`${window.api_origin}/login/recovery-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: data.otp_jwt_token,
|
||||
code: value,
|
||||
}),
|
||||
});
|
||||
|
||||
if ( resp.status === 429 ) {
|
||||
error_i18n_key = 'confirm_code_generic_too_many_requests';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
|
||||
const next_data = await resp.json();
|
||||
|
||||
if ( ! next_data.proceed ) {
|
||||
error_i18n_key = 'confirm_code_generic_incorrect';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
|
||||
data = next_data;
|
||||
|
||||
$(win).close();
|
||||
p.resolve();
|
||||
} catch (e) {
|
||||
set_error(i18n(error_i18n_key));
|
||||
}
|
||||
});
|
||||
},
|
||||
html: `
|
||||
<div class="recovery-code-entry">
|
||||
<form>
|
||||
<div class="error" style="display: none; color: red; border: 1px solid red; border-radius: 4px; padding: 9px; margin-bottom: 15px; text-align: center; font-size: 13px;"></div>
|
||||
<fieldset name="recovery-code" style="border: none; padding: 0; display: flex;" data-recovery-code-form>
|
||||
<input type="text" class="recovery-code-input" placeholder="${html_encode(i18n('login2fa_recovery_placeholder'))}" maxlength="8" required style="flex-grow: 1; box-sizing: border-box; height: 50px; font-size: 25px; text-align: center; border-radius: 0.5rem; font-family: 'Courier New', Courier, monospace;">
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
new Button({
|
||||
label: i18n('login2fa_recovery_back'),
|
||||
style: 'link',
|
||||
on_click: async () => {
|
||||
stepper.back();
|
||||
recovery_entry.clear_input();
|
||||
recovery_entry.clear_error();
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const component = stepper = new StepView({
|
||||
children: [otp_option, recovery_option],
|
||||
});
|
||||
win = await UIComponentWindow({
|
||||
component,
|
||||
width: 500,
|
||||
height: 410,
|
||||
backdrop: true,
|
||||
win = await UIWindow({
|
||||
title: null,
|
||||
app: 'login-2fa',
|
||||
single_instance: true,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h2fa,
|
||||
has_head: false,
|
||||
selectable_body: false,
|
||||
draggable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_resizable: false,
|
||||
is_draggable: true,
|
||||
is_droppable: false,
|
||||
init_center: true,
|
||||
allow_native_ctxmenu: false,
|
||||
allow_user_select: false,
|
||||
width: Math.min(400, window.innerWidth - 24),
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
is_draggable: false,
|
||||
backdrop: true,
|
||||
stay_on_top: true,
|
||||
center: true,
|
||||
window_class: 'window-login-2fa',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': 'rgb(245 247 249)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
padding: '20px',
|
||||
'background-color': '#f8fafc',
|
||||
padding: '32px 28px 24px',
|
||||
},
|
||||
on_close: () => {
|
||||
$(el_window).find('.login-btn').prop('disabled', false);
|
||||
},
|
||||
});
|
||||
component.focus();
|
||||
|
||||
const $w = $(win);
|
||||
|
||||
// ── Screen navigation ──
|
||||
function showScreen (name) {
|
||||
$w.find('.login-2fa-screen').removeClass('active');
|
||||
$w.find(`.login-2fa-screen[data-screen="${name}"]`).addClass('active');
|
||||
if ( name === 'otp' ) {
|
||||
setTimeout(() => $w.find('.login-2fa-code-inputs input').first().focus(), 80);
|
||||
} else {
|
||||
setTimeout(() => $w.find('.login-2fa-recovery-input').focus(), 80);
|
||||
}
|
||||
}
|
||||
|
||||
$w.find('.login-2fa-to-recovery').on('click', () => {
|
||||
$w.find('.login-2fa-code-inputs input').val('').removeClass('error');
|
||||
$w.find('.login-2fa-error').text('');
|
||||
showScreen('recovery');
|
||||
});
|
||||
$w.find('.login-2fa-to-otp').on('click', () => {
|
||||
$w.find('.login-2fa-recovery-input').val('');
|
||||
$w.find('.login-2fa-recovery-error').text('').hide();
|
||||
showScreen('otp');
|
||||
});
|
||||
|
||||
// ── OTP code input handling ──
|
||||
const $inputs = $w.find('.login-2fa-code-inputs input');
|
||||
let is_verifying = false;
|
||||
|
||||
$inputs.on('input', function () {
|
||||
const val = $(this).val().replace(/\D/g, '');
|
||||
$(this).val(val.slice(0, 1));
|
||||
$(this).removeClass('error');
|
||||
$w.find('.login-2fa-error').text('');
|
||||
|
||||
if ( val && $(this).data('idx') < 5 ) {
|
||||
$inputs.eq($(this).data('idx') + 1).focus();
|
||||
}
|
||||
|
||||
const code = $inputs.map(function () { return $(this).val(); }).get().join('');
|
||||
if ( code.length === 6 && ! is_verifying ) {
|
||||
verifyOtp(code);
|
||||
}
|
||||
});
|
||||
|
||||
$inputs.on('keydown', function (e) {
|
||||
const idx = $(this).data('idx');
|
||||
if ( e.key === 'Backspace' && ! $(this).val() && idx > 0 ) {
|
||||
$inputs.eq(idx - 1).focus().val('');
|
||||
}
|
||||
if ( e.key === 'ArrowLeft' && idx > 0 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx - 1).focus();
|
||||
}
|
||||
if ( e.key === 'ArrowRight' && idx < 5 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx + 1).focus();
|
||||
}
|
||||
});
|
||||
|
||||
$inputs.on('paste', function (e) {
|
||||
e.preventDefault();
|
||||
const pasted = (e.originalEvent.clipboardData || window.clipboardData)
|
||||
.getData('text').replace(/\D/g, '').slice(0, 6);
|
||||
if ( ! pasted ) return;
|
||||
for ( let i = 0; i < 6; i++ ) {
|
||||
$inputs.eq(i).val(pasted[i] || '');
|
||||
}
|
||||
$inputs.eq(Math.min(pasted.length, 6) - 1).focus();
|
||||
if ( pasted.length === 6 && ! is_verifying ) {
|
||||
verifyOtp(pasted);
|
||||
}
|
||||
});
|
||||
|
||||
async function verifyOtp (code) {
|
||||
is_verifying = true;
|
||||
$inputs.attr('disabled', true);
|
||||
$w.find('.login-2fa-spinner').addClass('visible');
|
||||
$w.find('.login-2fa-error').text('');
|
||||
let error_i18n_key = 'something_went_wrong';
|
||||
try {
|
||||
const resp = await fetch(`${window.gui_origin}/login/otp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: data.otp_jwt_token,
|
||||
code: code,
|
||||
}),
|
||||
});
|
||||
if ( resp.status === 429 ) {
|
||||
error_i18n_key = 'confirm_code_generic_too_many_requests';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
const next_data = await resp.json();
|
||||
if ( ! next_data.proceed ) {
|
||||
error_i18n_key = 'confirm_code_generic_incorrect';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
data = next_data;
|
||||
$w.find('.login-2fa-spinner').removeClass('visible');
|
||||
$(win).close();
|
||||
p.resolve();
|
||||
} catch (e) {
|
||||
$w.find('.login-2fa-spinner').removeClass('visible');
|
||||
$w.find('.login-2fa-error').text(i18n(error_i18n_key));
|
||||
$inputs.addClass('error').attr('disabled', false);
|
||||
setTimeout(() => {
|
||||
$inputs.val('').removeClass('error');
|
||||
$inputs.first().focus();
|
||||
}, 1200);
|
||||
}
|
||||
is_verifying = false;
|
||||
}
|
||||
|
||||
// ── Recovery code handling ──
|
||||
$w.find('.login-2fa-recovery-input').on('input', async function () {
|
||||
const value = $(this).val();
|
||||
if ( value.length !== 8 ) return;
|
||||
let error_i18n_key = 'something_went_wrong';
|
||||
try {
|
||||
const resp = await fetch(`${window.api_origin}/login/recovery-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: data.otp_jwt_token,
|
||||
code: value,
|
||||
}),
|
||||
});
|
||||
if ( resp.status === 429 ) {
|
||||
error_i18n_key = 'confirm_code_generic_too_many_requests';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
const next_data = await resp.json();
|
||||
if ( ! next_data.proceed ) {
|
||||
error_i18n_key = 'confirm_code_generic_incorrect';
|
||||
throw new Error('expected error');
|
||||
}
|
||||
data = next_data;
|
||||
$(win).close();
|
||||
p.resolve();
|
||||
} catch (e) {
|
||||
$w.find('.login-2fa-recovery-error').text(i18n(error_i18n_key)).show();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus first OTP input
|
||||
setTimeout(() => $inputs.first().focus(), 150);
|
||||
}
|
||||
|
||||
await p;
|
||||
|
||||
@@ -3007,89 +3007,6 @@ label {
|
||||
|
||||
}
|
||||
|
||||
/*****************************************************
|
||||
* System Information
|
||||
*****************************************************/
|
||||
|
||||
.systeminfo-container {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.serverinfo-container,
|
||||
.clientinfo-container {
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #cccccc8f;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.serverinfo-container h1,
|
||||
.clientinfo-container h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 10px;
|
||||
padding-left: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.update-usage-details-icon {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
/* For refresh button animation */
|
||||
.spin-once { animation: spin-once 1s linear; }
|
||||
|
||||
@keyframes spin-once {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.clientinfo-content,
|
||||
.serverinfo-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.systeminfo-item {
|
||||
flex: 1 1 45%; /* Grow, shrink, min width 45% */
|
||||
min-width: 150px; /* Prevents items from getting too small */
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px
|
||||
}
|
||||
|
||||
.systeminfo-value {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.systeminfo-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #3C4963;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.systeminfo-value {
|
||||
color: #3C4963;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.systeminfo-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/*****************************************************
|
||||
* Tooltip
|
||||
*****************************************************/
|
||||
|
||||
Reference in New Issue
Block a user