mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8b153f47 | ||
|
|
3bab9e8c6d | ||
|
|
0c4bf8bdbc | ||
|
|
fa7c1b0672 | ||
|
|
4422d87f95 | ||
|
|
63dad27fe7 | ||
|
|
cb44f17d2a | ||
|
|
075c0dd59b | ||
|
|
bd56c9fe73 | ||
|
|
f7b69003e0 | ||
|
|
5279e3cc43 | ||
|
|
27a656af9e | ||
|
|
c94f791e03 | ||
|
|
334710a4f9 | ||
|
|
e80199f61d | ||
|
|
b2c392558a | ||
|
|
8775649fc3 | ||
|
|
cec7fe746c | ||
|
|
4fe498edd5 | ||
|
|
67b2e8074e | ||
|
|
01e4ff9c12 |
No files matched your search
@@ -46,7 +46,7 @@ vi.mock('./UI/UIWindowFontPicker.js', stub);
|
||||
vi.mock('./UI/UIPermissionDialog.js', () => ({ default: permissionDialog }));
|
||||
vi.mock('./UI/UIEnablePushDialog.js', () => ({ default: dialog }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', stub);
|
||||
vi.mock('./UI/UIWindowSignup.js', stub);
|
||||
vi.mock('./UI/Auth/UIAuthSignup.js', stub);
|
||||
vi.mock('./UI/UINotification.js', stub);
|
||||
vi.mock('./helpers/godmodeTokens.js', () => ({ renewGodmodeToken: vi.fn() }));
|
||||
vi.mock('./helpers/watchItems.js', () => ({ addItemWatcher: vi.fn() }));
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock('./UI/UIWindowEmailConfirmationRequired.js', stub);
|
||||
vi.mock('./UI/UIWindowFontPicker.js', stub);
|
||||
vi.mock('./UI/UIPermissionDialog.js', stub);
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', stub);
|
||||
vi.mock('./UI/UIWindowSignup.js', stub);
|
||||
vi.mock('./UI/Auth/UIAuthSignup.js', stub);
|
||||
vi.mock('./UI/UINotification.js', stub);
|
||||
vi.mock('./helpers/godmodeTokens.js', () => ({ renewGodmodeToken: vi.fn() }));
|
||||
vi.mock('./helpers/watchItems.js', () => ({ addItemWatcher: vi.fn() }));
|
||||
|
||||
+12
-12
@@ -36,7 +36,7 @@ import UIWindowFontPicker from './UI/UIWindowFontPicker.js';
|
||||
import UIPermissionDialog from './UI/UIPermissionDialog.js';
|
||||
import UIEnablePushDialog from './UI/UIEnablePushDialog.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
import UIWindowSignup from './UI/UIWindowSignup.js';
|
||||
import UIAuthSignup from './UI/Auth/UIAuthSignup.js';
|
||||
import UINotification from './UI/UINotification.js';
|
||||
|
||||
import { renewGodmodeToken } from './helpers/godmodeTokens.js';
|
||||
@@ -611,7 +611,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'showOpenFilePicker' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -677,7 +677,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'showDirectoryPicker' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -783,7 +783,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'mouseMoved' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -818,7 +818,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'contextMenu' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1384,7 +1384,7 @@ const ipc_listener = async (event, handled) => {
|
||||
|
||||
// auth
|
||||
try {
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
respond(false);
|
||||
return;
|
||||
@@ -1490,7 +1490,7 @@ const ipc_listener = async (event, handled) => {
|
||||
try {
|
||||
// auth
|
||||
try {
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
respond(false);
|
||||
return;
|
||||
@@ -1543,7 +1543,7 @@ const ipc_listener = async (event, handled) => {
|
||||
enable_push_dialog_guard.markOpened();
|
||||
try {
|
||||
try {
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
respond('unavailable');
|
||||
return;
|
||||
@@ -1592,7 +1592,7 @@ const ipc_listener = async (event, handled) => {
|
||||
custom_domain_dialog_guard.markOpened();
|
||||
try {
|
||||
try {
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) ) {
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) ) {
|
||||
respond(null);
|
||||
return;
|
||||
}
|
||||
@@ -1624,7 +1624,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'showFontPicker' ) {
|
||||
// auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1657,7 +1657,7 @@ const ipc_listener = async (event, handled) => {
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'showColorPicker' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1699,7 +1699,7 @@ const ipc_listener = async (event, handled) => {
|
||||
}
|
||||
|
||||
//auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
if ( !window.is_auth() && !(await UIAuthSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The second factor of a login: a code from the user's authenticator app,
|
||||
// or one of their recovery codes. Verifies the challenge the first factor
|
||||
// (a password, or an identity provider) was answered with.
|
||||
|
||||
import { SVG } from '../Dashboard/UIDashboardDialog.js';
|
||||
import openAuthFlow, { keepCurrentSession, setAuthStatus } from './authFlow.js';
|
||||
|
||||
const CODE_LENGTH = 6;
|
||||
const RECOVERY_CODE_LENGTH = 8;
|
||||
// How long a wrong code stays on screen before the boxes clear for another try.
|
||||
const WRONG_CODE_MS = 1200;
|
||||
|
||||
const SHIELD_ICON = SVG('<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>');
|
||||
|
||||
/**
|
||||
* Sends a code for the challenge. Both routes are `guiOriginOnly` and carry
|
||||
* no `subdomain`, so they go to the root origin: on `api_origin` the
|
||||
* subdomain gate skips them and Express 404s.
|
||||
*
|
||||
* @param {'otp'|'recovery-code'} route
|
||||
* @param {string} token The first factor's challenge.
|
||||
* @param {string} code
|
||||
* @returns {Promise<{ ok: true, data: object } | { ok: false, error: string }>}
|
||||
* `error` is an i18n key.
|
||||
*/
|
||||
export const verifySecondFactor = async (route, token, code) => {
|
||||
try {
|
||||
const resp = await fetch(`${window.gui_origin}/login/${route}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, code }),
|
||||
});
|
||||
if ( resp.status === 429 ) return { ok: false, error: 'confirm_code_generic_too_many_requests' };
|
||||
const data = await resp.json();
|
||||
if ( ! data?.proceed ) return { ok: false, error: 'confirm_code_generic_incorrect' };
|
||||
return { ok: true, data };
|
||||
} catch {
|
||||
return { ok: false, error: 'something_went_wrong' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A screen's head: the shield, a title and what to do.
|
||||
*
|
||||
* @param {string} title i18n key.
|
||||
* @param {string} instructions i18n key.
|
||||
* @returns {string}
|
||||
*/
|
||||
const headHtml = (title, instructions) => '<div class="auth-head">'
|
||||
+ `<div class="auth-badge" aria-hidden="true">${SHIELD_ICON}</div>`
|
||||
+ `<h1 class="auth-title">${i18n(title)}</h1>`
|
||||
+ `<p class="auth-subtitle">${i18n(instructions)}</p>`
|
||||
+ '</div>';
|
||||
|
||||
/**
|
||||
* Shows the recovery code screen over the code screen; Back returns to it.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {string} token
|
||||
* @param {(data: object) => void} onVerified
|
||||
*/
|
||||
const pushRecoveryScreen = (flow, token, onVerified) => {
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += headHtml('login2fa_recovery_title', 'login2fa_recovery_instructions');
|
||||
h += '<form class="auth-form login-2fa-recovery-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += `<input type="text" class="dashboard-dialog-input login-2fa-recovery-input" maxlength="${RECOVERY_CODE_LENGTH}"`;
|
||||
h += ` placeholder="${html_encode(i18n('login2fa_recovery_placeholder', [], false))}" aria-label="${i18n('login2fa_recovery_title')}"`;
|
||||
h += ' autocomplete="off" autocapitalize="characters" autocorrect="off" spellcheck="false">';
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
|
||||
const $screen = flow.push({ name: 'recovery-code', html: h });
|
||||
const $input = $screen.find('.login-2fa-recovery-input');
|
||||
let verifying = false;
|
||||
|
||||
const verify = async () => {
|
||||
// Codes are 8-char base32; a pasted one usually carries the newline
|
||||
// it was copied with.
|
||||
const value = String($input.val() ?? '').trim().toUpperCase();
|
||||
if ( value.length !== RECOVERY_CODE_LENGTH || verifying ) return;
|
||||
verifying = true;
|
||||
flow.setBusy(true);
|
||||
setAuthStatus($screen, i18n('verifying', [], false), { kind: 'info' });
|
||||
const result = await verifySecondFactor('recovery-code', token, value);
|
||||
verifying = false;
|
||||
if ( flow.isClosed() ) {
|
||||
if ( result.ok ) keepCurrentSession();
|
||||
return;
|
||||
}
|
||||
if ( result.ok ) {
|
||||
onVerified(result.data);
|
||||
return;
|
||||
}
|
||||
flow.setBusy(false);
|
||||
setAuthStatus($screen, i18n(result.error, [], false), { field: '.login-2fa-recovery-input' });
|
||||
$input.get(0)?.select();
|
||||
};
|
||||
|
||||
$input.on('input', verify);
|
||||
$screen.find('form').on('submit', (e) => {
|
||||
e.preventDefault();
|
||||
verify();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the code screen in `flow`. Back leaves it unanswered.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {string} token The first factor's challenge.
|
||||
* @param {{ onBack?: () => void }} [opts] `onBack` makes Back available when
|
||||
* this is the flow's first screen.
|
||||
* @returns {Promise<object | null>} What the server answered a verified code
|
||||
* with (the session's token and user, and where to go); null when the user
|
||||
* went back or the flow closed.
|
||||
*/
|
||||
export function pushTwoFactorScreen (flow, token, { onBack } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value) => {
|
||||
if ( done ) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += headHtml('login2fa_otp_title', 'login2fa_otp_instructions');
|
||||
h += '<form class="auth-form login-2fa-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += `<div class="login-2fa-code-inputs" role="group" aria-label="${i18n('login2fa_otp_title')}">`;
|
||||
for ( let i = 0; i < CODE_LENGTH; i++ ) {
|
||||
// The first box takes a whole code from autofill or a paste.
|
||||
h += `<input type="text" class="dashboard-dialog-input" inputmode="numeric" pattern="[0-9]*" data-idx="${i}"`;
|
||||
h += ` aria-label="${i + 1}" autocomplete="${i === 0 ? 'one-time-code' : 'off'}">`;
|
||||
}
|
||||
h += '</div>';
|
||||
h += `<div class="auth-link-row"><button type="button" class="auth-link login-2fa-to-recovery">${i18n('login2fa_use_recovery_code')}</button></div>`;
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
|
||||
const $screen = flow.push({
|
||||
name: 'two-factor',
|
||||
html: h,
|
||||
onBack: onBack ?? undefined,
|
||||
onLeave: () => finish(null),
|
||||
});
|
||||
const $inputs = $screen.find('.login-2fa-code-inputs input');
|
||||
let verifying = false;
|
||||
|
||||
const code = () => $inputs.map(function () {
|
||||
return $(this).val();
|
||||
}).get().join('');
|
||||
|
||||
const verified = (data) => finish(data);
|
||||
|
||||
const verify = async (value) => {
|
||||
if ( verifying || value.length !== CODE_LENGTH ) return;
|
||||
verifying = true;
|
||||
flow.setBusy(true);
|
||||
$inputs.prop('disabled', true);
|
||||
setAuthStatus($screen, i18n('verifying', [], false), { kind: 'info' });
|
||||
const result = await verifySecondFactor('otp', token, value);
|
||||
verifying = false;
|
||||
// Dismissed while the code was out: stay signed in as before.
|
||||
if ( flow.isClosed() ) {
|
||||
if ( result.ok ) keepCurrentSession();
|
||||
return;
|
||||
}
|
||||
if ( result.ok ) {
|
||||
verified(result.data);
|
||||
return;
|
||||
}
|
||||
flow.setBusy(false);
|
||||
$inputs.prop('disabled', false);
|
||||
setAuthStatus($screen, i18n(result.error, [], false));
|
||||
$inputs.addClass('login-2fa-wrong').attr('aria-invalid', 'true');
|
||||
setTimeout(() => {
|
||||
if ( verifying ) return;
|
||||
$inputs.val('').removeClass('login-2fa-wrong').removeAttr('aria-invalid');
|
||||
if ( ! $screen.prop('hidden') ) $inputs.first().trigger('focus');
|
||||
}, WRONG_CODE_MS);
|
||||
};
|
||||
|
||||
/** Spreads digits over the boxes from `start`, and verifies a full code. */
|
||||
const fill = (digits, start) => {
|
||||
for ( let i = 0; i < digits.length && start + i < CODE_LENGTH; i++ ) {
|
||||
$inputs.eq(start + i).val(digits[i]);
|
||||
}
|
||||
const next = Math.min(start + digits.length, CODE_LENGTH - 1);
|
||||
$inputs.eq(next).trigger('focus');
|
||||
verify(code());
|
||||
};
|
||||
|
||||
$inputs.on('input', function () {
|
||||
const idx = Number($(this).attr('data-idx'));
|
||||
const digits = String($(this).val() ?? '').replace(/\D/g, '');
|
||||
$inputs.removeClass('login-2fa-wrong').removeAttr('aria-invalid');
|
||||
if ( ! digits ) {
|
||||
$(this).val('');
|
||||
return;
|
||||
}
|
||||
// One digit typed, or a whole code autofilled into this box.
|
||||
$(this).val(digits[0]);
|
||||
if ( digits.length > 1 ) {
|
||||
fill(digits.slice(1), idx + 1);
|
||||
return;
|
||||
}
|
||||
if ( idx < CODE_LENGTH - 1 ) $inputs.eq(idx + 1).trigger('focus');
|
||||
verify(code());
|
||||
});
|
||||
|
||||
$inputs.on('keydown', function (e) {
|
||||
const idx = Number($(this).attr('data-idx'));
|
||||
if ( e.key === 'Backspace' && ! $(this).val() && idx > 0 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx - 1).val('').trigger('focus');
|
||||
} else if ( e.key === 'ArrowLeft' && idx > 0 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx - 1).trigger('focus');
|
||||
} else if ( e.key === 'ArrowRight' && idx < CODE_LENGTH - 1 ) {
|
||||
e.preventDefault();
|
||||
$inputs.eq(idx + 1).trigger('focus');
|
||||
}
|
||||
});
|
||||
|
||||
$inputs.on('focus', function () {
|
||||
this.select();
|
||||
});
|
||||
|
||||
$inputs.on('paste', function (e) {
|
||||
e.preventDefault();
|
||||
const clipboard = e.originalEvent?.clipboardData ?? window.clipboardData;
|
||||
const pasted = String(clipboard?.getData('text') ?? '').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
||||
if ( ! pasted ) return;
|
||||
$inputs.val('');
|
||||
fill(pasted, 0);
|
||||
});
|
||||
|
||||
$screen.find('form').on('submit', (e) => {
|
||||
e.preventDefault();
|
||||
verify(code());
|
||||
});
|
||||
|
||||
$screen.find('.login-2fa-to-recovery').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
$inputs.val('').removeClass('login-2fa-wrong').removeAttr('aria-invalid');
|
||||
setAuthStatus($screen, '');
|
||||
pushRecoveryScreen(flow, token, verified);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the second factor of a login on a page of its own (an identity
|
||||
* provider's login that came back needing one). The card stays up, busy,
|
||||
* once the code is verified: the caller is expected to navigate.
|
||||
*
|
||||
* @param {string} token The first factor's challenge.
|
||||
* @returns {Promise<object | null>} The server's answer to a verified code;
|
||||
* null when the user chose to go back to the login.
|
||||
*/
|
||||
export default function UIAuth2FA (token) {
|
||||
const flow = openAuthFlow({ page: true });
|
||||
const answer = pushTwoFactorScreen(flow, token, {
|
||||
onBack: () => flow.settle(null),
|
||||
});
|
||||
answer.then((data) => {
|
||||
if ( data ) flow.setBusy(true);
|
||||
flow.settle(data);
|
||||
});
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The account picker: the accounts signed in on this browser, to continue
|
||||
// as one of them, log in to another, or create one. In a sign-in popup,
|
||||
// picking an account is the user's consent to sign the opener in.
|
||||
|
||||
import { SVG } from '../Dashboard/UIDashboardDialog.js';
|
||||
import openAuthFlow, { authHeadHtml, joinAuthFlow, wireAuthHead } from './authFlow.js';
|
||||
import { pushLoginScreen } from './UIAuthLogin.js';
|
||||
import { pushSignupScreen } from './UIAuthSignup.js';
|
||||
|
||||
const CHEVRON_ICON = SVG('<path d="M9 18l6-6-6-6"/>');
|
||||
|
||||
/**
|
||||
* One account's row.
|
||||
*
|
||||
* @param {{ uuid: string, username: string, email?: string, profile?: { picture?: string } }} account
|
||||
* @returns {string}
|
||||
*/
|
||||
const accountRowHtml = (account) => {
|
||||
const username = String(account.username ?? '');
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
let h = '<li>';
|
||||
h += `<button type="button" class="auth-account" data-uuid="${html_encode(account.uuid)}">`;
|
||||
h += `<span class="auth-account-avatar" aria-hidden="true">${html_encode(initial)}</span>`;
|
||||
h += '<span class="auth-account-text">';
|
||||
h += `<span class="auth-account-name">${html_encode(username)}</span>`;
|
||||
if ( account.email ) h += `<span class="auth-account-detail">${html_encode(account.email)}</span>`;
|
||||
h += '</span>';
|
||||
h += '<span class="dashboard-form-spinner auth-account-spinner" aria-hidden="true"></span>';
|
||||
h += `<span class="auth-account-chevron" aria-hidden="true">${CHEVRON_ICON}</span>`;
|
||||
h += '</button>';
|
||||
h += '</li>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lays each account's picture over its initial. Set as a property, never
|
||||
* through markup; a picture that fails to load leaves the initial.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
* @param {Array<{ uuid: string, profile?: { picture?: string } }>} accounts
|
||||
*/
|
||||
const paintPictures = ($screen, accounts) => {
|
||||
for ( const account of accounts ) {
|
||||
const picture = account.profile?.picture;
|
||||
if ( typeof picture !== 'string' || ! picture ) continue;
|
||||
const el_avatar = $screen.find('.auth-account').filter((_, el) => el.dataset.uuid === String(account.uuid))
|
||||
.find('.auth-account-avatar').get(0);
|
||||
if ( ! el_avatar ) continue;
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.draggable = false;
|
||||
img.addEventListener('error', () => img.remove(), { once: true });
|
||||
img.src = picture;
|
||||
el_avatar.appendChild(img);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the account picker in `flow`.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
export function pushAccountPickerScreen (flow) {
|
||||
const accounts = Array.isArray(window.logged_in_users) ? window.logged_in_users : [];
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('sign_in_with_puter', [], false));
|
||||
h += `<ul class="auth-accounts" aria-label="${i18n('sign_in_with_puter')}">`;
|
||||
h += accounts.map(accountRowHtml).join('');
|
||||
h += '</ul>';
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-dialog-footer auth-footer">';
|
||||
h += `<button type="button" class="auth-link auth-to-login">${i18n('add_another_account')}</button>`;
|
||||
h += '<span class="auth-footer-sep" aria-hidden="true">·</span>';
|
||||
h += `<button type="button" class="auth-link auth-to-signup">${i18n('create_account')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
// Nothing to type here; focus lands on the card, not on an account.
|
||||
const $screen = flow.push({ name: 'accounts', html: h, autofocus: false });
|
||||
wireAuthHead($screen);
|
||||
paintPictures($screen, accounts);
|
||||
|
||||
$screen.find('.auth-to-login').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('login') ) pushLoginScreen(flow);
|
||||
});
|
||||
$screen.find('.auth-to-signup').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('signup') ) pushSignupScreen(flow);
|
||||
});
|
||||
|
||||
$screen.find('.auth-account').on('click', async function () {
|
||||
if ( flow.isBusy() ) return;
|
||||
const uuid = this.dataset.uuid;
|
||||
const account = accounts.find((candidate) => String(candidate.uuid) === uuid);
|
||||
if ( ! account ) return;
|
||||
flow.setBusy(true);
|
||||
$screen.find('.auth-account').prop('disabled', true);
|
||||
$(this).addClass('dashboard-form-busy').attr('aria-busy', 'true');
|
||||
|
||||
await window.update_auth_data(account.auth_token, account);
|
||||
if ( flow.options.reload_on_success ) {
|
||||
// Disable the browser's own "leave this page?" prompt.
|
||||
window.onbeforeunload = null;
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
flow.finish(true);
|
||||
});
|
||||
|
||||
return $screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks which of the accounts signed in on this browser to continue as.
|
||||
*
|
||||
* @param {Partial<import('./authFlow.js').AuthFlowOptions> & { page?: boolean }} [options]
|
||||
* The picker can't be dismissed: it stands between a sign-in popup and
|
||||
* the token it hands out, and on a signed-out boot it is the page.
|
||||
* @returns {Promise<boolean>} True once an account is picked or signed in,
|
||||
* unless the page reloads first.
|
||||
*/
|
||||
export default function UIAuthAccountPicker (options = {}) {
|
||||
const joined = joinAuthFlow();
|
||||
if ( joined ) return joined;
|
||||
const flow = openAuthFlow({ page: options.page, dismissable: false, options });
|
||||
pushAccountPickerScreen(flow);
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The login screen: email or username and password, the identity provider
|
||||
// buttons, and the ways to the signup and password recovery screens.
|
||||
|
||||
import { bindPasswordReveal, formFieldHtml } from '../Dashboard/UIDashboardFormDialog.js';
|
||||
import openAuthFlow, {
|
||||
authHeadHtml,
|
||||
joinAuthFlow,
|
||||
keepCurrentSession,
|
||||
navigateAfterSignIn,
|
||||
setAuthStatus,
|
||||
setFormBusy,
|
||||
submitButtonHtml,
|
||||
wireAuthHead,
|
||||
} from './authFlow.js';
|
||||
import { renderProviderButtons } from './authProviders.js';
|
||||
import { pushTwoFactorScreen } from './UIAuth2FA.js';
|
||||
import { pushRecoverPasswordScreen } from './UIAuthRecoverPassword.js';
|
||||
import { pushSignupScreen } from './UIAuthSignup.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} LoginScreenOptions
|
||||
* @property {string} [email_or_username] Prefills the first field.
|
||||
* @property {string} [authError] Shown when the screen opens.
|
||||
* @property {string} [auth_id] Sent with the login when it follows a
|
||||
* `reauth_required` 401, so the backend re-attaches the same identity.
|
||||
* @property {string} [reauth_reason]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clears credentials a link may have put in the address bar.
|
||||
*/
|
||||
const clearCredentialsFromUrl = () => {
|
||||
const search = window.location.search;
|
||||
if ( search && (search.includes('password=') || search.includes('username=') || search.includes('email=')) ) {
|
||||
history.replaceState({}, document.title, window.location.origin + window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* What to tell the user about a failed login request.
|
||||
*
|
||||
* @param {{ status?: number, responseText?: string }} err
|
||||
* @returns {{ text?: string, html?: string }}
|
||||
*/
|
||||
export const loginErrorMessage = (err) => {
|
||||
const response_text = err?.responseText || '';
|
||||
try {
|
||||
const json = JSON.parse(response_text);
|
||||
if ( json?.message ) return { text: String(json.message) };
|
||||
} catch {
|
||||
// Not JSON.
|
||||
}
|
||||
if ( err?.status === 404 ) {
|
||||
// Not the whole 404 page.
|
||||
return { text: `Error 404: "${window.gui_origin}/login" not found` };
|
||||
}
|
||||
if ( response_text ) return { text: response_text };
|
||||
// No answer at all usually means the server couldn't be reached; on a
|
||||
// self-hosted instance that's most likely its configuration.
|
||||
if ( window.app_domain !== 'puter.com' ) {
|
||||
const origin = html_encode(window.gui_origin);
|
||||
return {
|
||||
html: '<div class="auth-status-help">'
|
||||
+ `<p>Error reaching "${origin}/login". This is likely to be a configuration issue.</p>`
|
||||
+ '<p>Make sure of the following:</p>'
|
||||
+ '<ul>'
|
||||
+ '<li><code>domain</code> in config.json is set to the domain you\'re using to access puter</li>'
|
||||
+ '<li>DNS resolves for the domain, and the <code>api.</code> subdomain on that domain</li>'
|
||||
+ '<li><code>http_port</code> is set to the port Puter is listening on (<code>auto</code> will use <code>4100</code> unless that port is in use)</li>'
|
||||
+ '<li><code>pub_port</code> is set to the external port (ex: <code>443</code> if you\'re using a reverse proxy that serves over https)</li>'
|
||||
+ '</ul></div>',
|
||||
};
|
||||
}
|
||||
return { text: `Failed to log in: Error ${err?.status ?? ''}`.trim() };
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the login screen in `flow`.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {LoginScreenOptions} [options]
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
export function pushLoginScreen (flow, options = {}) {
|
||||
const id = `auth-login-${window.uuidv4?.() ?? Date.now()}`;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('log_in', [], false));
|
||||
h += '<form class="auth-form login-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += formFieldHtml({
|
||||
name: 'email_or_username',
|
||||
label: i18n('email_or_username'),
|
||||
value: options.email_or_username ?? '',
|
||||
attrs: { autocomplete: 'username', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false' },
|
||||
}, id);
|
||||
h += formFieldHtml({
|
||||
name: 'password',
|
||||
type: 'password',
|
||||
label: i18n('password'),
|
||||
attrs: { autocomplete: 'current-password' },
|
||||
}, id);
|
||||
h += `<div class="auth-forgot-row"><button type="button" class="auth-link forgot-password-link">${i18n('forgot_pass_c2a')}</button></div>`;
|
||||
h += submitButtonHtml(i18n('log_in', [], false), 'login-btn');
|
||||
h += '</form>';
|
||||
h += '<div class="auth-providers" hidden>';
|
||||
h += `<div class="auth-divider"><span>${i18n('or')}</span></div>`;
|
||||
h += '<div class="auth-provider-list"></div>';
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
if ( flow.options.show_signup_button ) {
|
||||
h += '<div class="dashboard-dialog-footer auth-footer">';
|
||||
h += `<button type="button" class="auth-link auth-to-signup">${i18n('create_free_account')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
const $screen = flow.push({
|
||||
name: 'login',
|
||||
html: h,
|
||||
autofocus: ! window.disable_login_autofocus,
|
||||
});
|
||||
const $form = $screen.find('.login-form');
|
||||
const $user = $form.find('[name="email_or_username"]');
|
||||
const $password = $form.find('[name="password"]');
|
||||
|
||||
wireAuthHead($screen);
|
||||
bindPasswordReveal($form);
|
||||
if ( options.authError ) setAuthStatus($screen, options.authError);
|
||||
|
||||
$form.on('input', '.dashboard-form-input', function () {
|
||||
$(this).removeAttr('aria-invalid');
|
||||
});
|
||||
|
||||
renderProviderButtons($screen.find('.auth-provider-list'), {
|
||||
kind: 'login',
|
||||
referrer: () => flow.options.referrer ?? window.referrerStr ?? window.openerOrigin,
|
||||
}).then((offered) => {
|
||||
if ( offered ) $screen.find('.auth-providers').prop('hidden', false);
|
||||
});
|
||||
|
||||
$screen.find('.forgot-password-link').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
pushRecoverPasswordScreen(flow, { email_or_username: String($user.val() ?? '').trim() });
|
||||
});
|
||||
|
||||
$screen.find('.auth-to-signup').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('signup') ) pushSignupScreen(flow);
|
||||
});
|
||||
|
||||
// The form first, so the end of busy can hand focus back to a field.
|
||||
const unlock = () => {
|
||||
setFormBusy($form, false);
|
||||
flow.setBusy(false);
|
||||
};
|
||||
|
||||
$form.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ( flow.isBusy() ) return;
|
||||
|
||||
const email_username = String($user.val() ?? '').trim();
|
||||
const password = String($password.val() ?? '');
|
||||
if ( ! email_username ) {
|
||||
setAuthStatus($screen, i18n('login_email_username_required', [], false), { field: '[name="email_or_username"]' });
|
||||
return;
|
||||
}
|
||||
if ( ! password ) {
|
||||
setAuthStatus($screen, i18n('login_password_required', [], false), { field: '[name="password"]' });
|
||||
return;
|
||||
}
|
||||
setAuthStatus($screen, '');
|
||||
|
||||
// After a `reauth_required` 401, the login re-attaches the same
|
||||
// stable identity.
|
||||
const reauth = options.auth_id ? { auth_id: options.auth_id, reauth_reason: options.reauth_reason } : {};
|
||||
const body = window.is_email(email_username)
|
||||
? { email: email_username, password, ...reauth }
|
||||
: { username: email_username, password, ...reauth };
|
||||
|
||||
flow.setBusy(true);
|
||||
setFormBusy($form, true);
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: `${window.gui_origin}/login`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: window.custom_headers ?? {},
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(body),
|
||||
success: resolve,
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
} catch ( err ) {
|
||||
clearCredentialsFromUrl();
|
||||
unlock();
|
||||
if ( flow.isClosed() ) return;
|
||||
const message = loginErrorMessage(err);
|
||||
setAuthStatus($screen, message.text ?? '', { html: message.html });
|
||||
// A refused password is typed again; a refused name is fixed there.
|
||||
$password.get(0)?.focus({ preventScroll: true });
|
||||
$password.get(0)?.select?.();
|
||||
return;
|
||||
}
|
||||
// Dismissed while the request was out: the caller already heard no,
|
||||
// so this browser stays signed in as it was.
|
||||
if ( flow.isClosed() ) {
|
||||
if ( data?.token ) keepCurrentSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( data?.next_step === 'otp' ) {
|
||||
// The second factor is a screen of its own; Back returns here.
|
||||
flow.setBusy(false);
|
||||
const verified = await pushTwoFactorScreen(flow, data.otp_jwt_token);
|
||||
if ( ! verified ) {
|
||||
setFormBusy($form, false);
|
||||
if ( ! flow.isClosed() ) flow.focus();
|
||||
return;
|
||||
}
|
||||
flow.setBusy(true);
|
||||
data = verified;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.update_auth_data(data.token, data.user);
|
||||
} catch ( err ) {
|
||||
unlock();
|
||||
// Back from a verified code, whose screen would otherwise stay up, busy.
|
||||
if ( $screen.prop('hidden') ) flow.goTo('login');
|
||||
setAuthStatus($screen, err?.message || i18n('something_went_wrong', [], false));
|
||||
return;
|
||||
}
|
||||
|
||||
if ( flow.options.reload_on_success ) {
|
||||
// A clean URL, so no credentials stay in it. The card stays up,
|
||||
// busy, while the page goes.
|
||||
navigateAfterSignIn(flow.options.redirect_url || window.location.origin + window.location.pathname);
|
||||
return;
|
||||
}
|
||||
flow.finish(true);
|
||||
});
|
||||
|
||||
return $screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user to log in.
|
||||
*
|
||||
* @param {Partial<import('./authFlow.js').AuthFlowOptions> & LoginScreenOptions & {
|
||||
* page?: boolean, dismissable?: boolean, closeOnBackdrop?: boolean,
|
||||
* }} [options] `page`, `dismissable` and `closeOnBackdrop` as in `openAuthFlow`.
|
||||
* @returns {Promise<boolean>} True once signed in, unless the page reloads
|
||||
* first; false when the user dismisses it. Joins an auth flow that is
|
||||
* already open instead of opening another.
|
||||
*/
|
||||
export default function UIAuthLogin (options = {}) {
|
||||
const joined = joinAuthFlow();
|
||||
if ( joined ) return joined;
|
||||
const flow = openAuthFlow({
|
||||
page: options.page,
|
||||
dismissable: options.dismissable,
|
||||
closeOnBackdrop: options.closeOnBackdrop,
|
||||
options,
|
||||
});
|
||||
pushLoginScreen(flow, options);
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reply, resetAuthGlobals, screen, tick } from './authTestSetup.js';
|
||||
|
||||
vi.mock('../UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
|
||||
const { activeAuthFlow } = await import('./authFlow.js');
|
||||
const { default: UIAuthLogin, loginErrorMessage } = await import('./UIAuthLogin.js');
|
||||
const { default: UIAuth2FA } = await import('./UIAuth2FA.js');
|
||||
|
||||
const completed = { proceed: true, next_step: 'complete', token: 'gui-token', user: { username: 'user' }, redirectUrl: 'https://puter.test/dashboard' };
|
||||
|
||||
/** Captures the login request; returns its callbacks. */
|
||||
const captureAjax = () => {
|
||||
const calls = [];
|
||||
vi.spyOn($, 'ajax').mockImplementation((opts) => {
|
||||
calls.push(opts);
|
||||
});
|
||||
return calls;
|
||||
};
|
||||
|
||||
const submitLogin = (user = 'user', password = 'correct-password') => {
|
||||
$('[name="email_or_username"]').val(user);
|
||||
$('[name="password"]').val(password);
|
||||
$('.login-form').trigger('submit');
|
||||
};
|
||||
|
||||
const enterOtp = (code) => {
|
||||
const inputs = $('.login-2fa-code-inputs input');
|
||||
[...code].forEach((digit, index) => inputs.eq(index).val(digit));
|
||||
inputs.last().trigger('input');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthGlobals();
|
||||
});
|
||||
afterEach(() => {
|
||||
activeAuthFlow()?.finish(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('login screen', () => {
|
||||
it('asks for each field before sending anything', () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
$('.login-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('login_email_username_required');
|
||||
expect($('[name="email_or_username"]').attr('aria-invalid')).toBe('true');
|
||||
$('[name="email_or_username"]').val('user');
|
||||
$('.login-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('login_password_required');
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('logs in by username or email, forwarding a reauth identity', async () => {
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin({ reload_on_success: false, auth_id: 'aid', reauth_reason: 'rotated' });
|
||||
submitLogin(' [email protected] ');
|
||||
expect(JSON.parse(calls[0].data)).toEqual({ email: '[email protected]', password: 'correct-password', auth_id: 'aid', reauth_reason: 'rotated' });
|
||||
expect($('.login-btn').hasClass('dashboard-form-busy')).toBe(true);
|
||||
calls[0].success({ token: 't', user: { username: 'someone' } });
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.update_auth_data).toHaveBeenCalledWith('t', { username: 'someone' });
|
||||
expect(activeAuthFlow()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the server message, unlocks, and puts the user back on the password', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin('user', 'nope');
|
||||
calls[0].error({ status: 400, responseText: JSON.stringify({ message: '<b>Wrong</b> password' }) });
|
||||
await tick();
|
||||
// Text, never markup from the server.
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('<b>Wrong</b> password');
|
||||
expect(screen().find('.dashboard-form-status b')).toHaveLength(0);
|
||||
expect($('[name="password"]').prop('disabled')).toBe(false);
|
||||
expect(activeAuthFlow().isBusy()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays signed in as before when dismissed while the login is out', async () => {
|
||||
window.auth_token = 'current-token';
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin();
|
||||
submitLogin();
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(result).resolves.toBe(false);
|
||||
calls[0].success({ token: 't', user: { username: 'other' } });
|
||||
await tick();
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
// The server answered by moving the session cookie; it comes back.
|
||||
expect(fetch).toHaveBeenCalledWith('https://puter.test/session/sync-cookie', expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer current-token' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('leaves the session cookie alone when a dismissed login only asked for a code', async () => {
|
||||
window.auth_token = 'current-token';
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin();
|
||||
submitLogin();
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'c' });
|
||||
await tick();
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).endsWith('/session/sync-cookie'))).toBe(false);
|
||||
});
|
||||
|
||||
it('encodes a prefilled username', () => {
|
||||
void UIAuthLogin({ email_or_username: '"><img src=x onerror=alert(1)>' });
|
||||
expect($('.auth-dialog img[src="x"]')).toHaveLength(0);
|
||||
expect($('[name="email_or_username"]').val()).toBe('"><img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('hides the signup link when asked to', () => {
|
||||
void UIAuthLogin({ show_signup_button: false });
|
||||
expect($('.auth-to-signup')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens forgot password in place, prefilled, and comes back', () => {
|
||||
void UIAuthLogin();
|
||||
$('[name="email_or_username"]').val('someone');
|
||||
$('.forgot-password-link').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('recover-password');
|
||||
expect(screen().find('[name="email_or_username"]').val()).toBe('someone');
|
||||
screen().find('.auth-to-login').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('login');
|
||||
expect($('.auth-screen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('switches to signup and back without stacking screens', () => {
|
||||
void UIAuthLogin();
|
||||
$('.auth-to-signup').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('signup');
|
||||
screen().find('.auth-to-login').trigger('click');
|
||||
expect($('.auth-screen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('joins a login that is already open', async () => {
|
||||
const first = UIAuthLogin({ reload_on_success: false });
|
||||
const second = UIAuthLogin({ reload_on_success: false });
|
||||
expect($('.auth-dialog')).toHaveLength(1);
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(first).resolves.toBe(false);
|
||||
await expect(second).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('explains an unreachable self-hosted server', () => {
|
||||
window.app_domain = 'my.host';
|
||||
expect(loginErrorMessage({ status: 0 }).html).toContain('config.json');
|
||||
window.app_domain = 'puter.com';
|
||||
expect(loginErrorMessage({ status: 0 }).text).toBe('Failed to log in: Error 0');
|
||||
expect(loginErrorMessage({ status: 404, responseText: '<html>' }).text).toContain('404');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC buttons on the login and signup screens', () => {
|
||||
const PROVIDERS_URL = 'https://api.puter.test/auth/oidc/providers';
|
||||
const asPopup = (query) => {
|
||||
window.embedded_in_popup = true;
|
||||
window.gui_action = 'sign-in';
|
||||
window.url_query_params = new URLSearchParams(query);
|
||||
};
|
||||
const askedForProviders = () => fetch.mock.calls.some(([url]) => url === PROVIDERS_URL);
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn(async () => reply(200, { providers: ['google'] }));
|
||||
});
|
||||
|
||||
it.each(['login', 'signup'])('hides them in a cross-origin-isolated relay popup (%s)', async (kind) => {
|
||||
asPopup('embedded_in_popup=true&msg_id=1&cross_origin_isolated=true&signin_session=s');
|
||||
void UIAuthLogin();
|
||||
if ( kind === 'signup' ) $('.auth-to-signup').trigger('click');
|
||||
await tick();
|
||||
expect(askedForProviders()).toBe(false);
|
||||
expect(screen().find('.oidc-google-btn')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(['login', 'signup'])('keeps them in a regular sign-in popup (%s)', async (kind) => {
|
||||
asPopup('embedded_in_popup=true&msg_id=1');
|
||||
void UIAuthLogin();
|
||||
if ( kind === 'signup' ) $('.auth-to-signup').trigger('click');
|
||||
await vi.waitFor(() => expect(screen().find('.oidc-google-btn')).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('starts the provider login with where to come back to', async () => {
|
||||
void UIAuthLogin({ referrer: 'ref' });
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
expect($('.auth-providers').prop('hidden')).toBe(false);
|
||||
const assign = vi.fn();
|
||||
const original = window.location;
|
||||
delete window.location;
|
||||
window.location = { ...original, set href (url) {
|
||||
assign(url);
|
||||
}, get href () {
|
||||
return original.href;
|
||||
} };
|
||||
$('.oidc-google-btn').trigger('click');
|
||||
window.location = original;
|
||||
expect(assign.mock.calls[0][0]).toBe('https://puter.test/auth/oidc/google/start?flow=login&referrer=ref');
|
||||
});
|
||||
});
|
||||
|
||||
describe('second factor', () => {
|
||||
it('passes leading-zero codes to the verifier and keeps the continuation data', async () => {
|
||||
fetch.mockResolvedValue(reply(200, completed));
|
||||
const result = UIAuth2FA('first-factor-challenge');
|
||||
enterOtp('001234');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(fetch).toHaveBeenCalledWith('https://puter.test/login/otp', expect.objectContaining({
|
||||
body: JSON.stringify({ token: 'first-factor-challenge', code: '001234' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('takes a whole code autofilled into the first box', async () => {
|
||||
fetch.mockResolvedValue(reply(200, completed));
|
||||
const result = UIAuth2FA('t');
|
||||
$('.login-2fa-code-inputs input').first().val('987654').trigger('input');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body).code).toBe('987654');
|
||||
});
|
||||
|
||||
it('keeps a failed challenge open for a recovery-code retry', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { proceed: false })).mockResolvedValueOnce(reply(200, completed));
|
||||
const result = UIAuth2FA('first-factor-challenge');
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(screen().find('.dashboard-form-status').text()).toBe('confirm_code_generic_incorrect'));
|
||||
$('.login-2fa-to-recovery').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('recovery-code');
|
||||
$('.login-2fa-recovery-input').val(' abcdefg2\n').trigger('input');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(fetch.mock.calls[1][0]).toBe('https://puter.test/login/recovery-code');
|
||||
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ token: 'first-factor-challenge', code: 'ABCDEFG2' });
|
||||
});
|
||||
|
||||
it('reports throttling without completing', async () => {
|
||||
fetch.mockResolvedValue(reply(429, {}));
|
||||
const result = UIAuth2FA('t');
|
||||
let settled = false;
|
||||
result.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(screen().find('.dashboard-form-status').text()).toBe('confirm_code_generic_too_many_requests'));
|
||||
expect(settled).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores incomplete recovery codes', () => {
|
||||
void UIAuth2FA('t');
|
||||
$('.login-2fa-to-recovery').trigger('click');
|
||||
$('.login-2fa-recovery-input').val('ABC').trigger('input');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves null when the user goes back from a standalone challenge', async () => {
|
||||
const result = UIAuth2FA('t');
|
||||
$('.auth-dialog-back').trigger('click');
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('password login with a second factor', () => {
|
||||
it('completes only after the challenge succeeds', async () => {
|
||||
fetch.mockImplementation(async (url) => (String(url).endsWith('/providers') ? reply(200, { providers: [] }) : reply(200, completed)));
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin();
|
||||
const submitted = calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'password-challenge' });
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('two-factor'));
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
enterOtp('001234');
|
||||
await submitted;
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.update_auth_data).toHaveBeenCalledWith(completed.token, completed.user);
|
||||
expect(JSON.parse(fetch.mock.calls.at(-1)[1].body).token).toBe('password-challenge');
|
||||
});
|
||||
|
||||
it('shows a failure to finish signing in on the login screen, not a stuck code screen', async () => {
|
||||
fetch.mockImplementation(async (url) => (String(url).endsWith('/providers') ? reply(200, { providers: [] }) : reply(200, completed)));
|
||||
window.update_auth_data = vi.fn(async () => {
|
||||
throw new Error('Storage is full');
|
||||
});
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin();
|
||||
calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'c' });
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('two-factor'));
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('login'));
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('Storage is full');
|
||||
expect($('.login-btn').prop('disabled')).toBe(false);
|
||||
expect(activeAuthFlow().isBusy()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays signed in as before when dismissed while the code is out', async () => {
|
||||
window.auth_token = 'current-token';
|
||||
let answer;
|
||||
fetch.mockImplementation(async (url) => {
|
||||
if ( String(url).endsWith('/providers') ) return reply(200, { providers: [] });
|
||||
if ( String(url).endsWith('/login/otp') ) return new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
return reply(204, null);
|
||||
});
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin();
|
||||
const submitted = calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'c' });
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('two-factor'));
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(answer).toBeTypeOf('function'));
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(result).resolves.toBe(false);
|
||||
answer(reply(200, completed));
|
||||
await submitted;
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith('https://puter.test/session/sync-cookie', expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer current-token' },
|
||||
})));
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns to the enabled password form when the user goes back', async () => {
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthLogin({ reload_on_success: false });
|
||||
submitLogin();
|
||||
const submitted = calls[0].success({ proceed: true, next_step: 'otp', otp_jwt_token: 'c' });
|
||||
await vi.waitFor(() => expect(screen().attr('data-screen')).toBe('two-factor'));
|
||||
$('.auth-dialog-back').trigger('click');
|
||||
await submitted;
|
||||
expect(screen().attr('data-screen')).toBe('login');
|
||||
expect($('.login-btn').prop('disabled')).toBe(false);
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The password recovery screen: mails a link to set a new password.
|
||||
|
||||
import { formFieldHtml } from '../Dashboard/UIDashboardFormDialog.js';
|
||||
import { SVG } from '../Dashboard/UIDashboardDialog.js';
|
||||
import openAuthFlow, {
|
||||
authHeadHtml,
|
||||
joinAuthFlow,
|
||||
setAuthStatus,
|
||||
setFormBusy,
|
||||
submitButtonHtml,
|
||||
wireAuthHead,
|
||||
} from './authFlow.js';
|
||||
import { pushLoginScreen } from './UIAuthLogin.js';
|
||||
|
||||
const SENT_ICON = SVG('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5L16 9.5"/>');
|
||||
|
||||
/**
|
||||
* The message of a failed request.
|
||||
*
|
||||
* @param {{ responseText?: string }} err
|
||||
* @returns {string}
|
||||
*/
|
||||
const errorMessage = (err) => {
|
||||
const text = err?.responseText || '';
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json.message || json.error || text;
|
||||
} catch {
|
||||
return text || i18n('something_went_wrong', [], false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the password recovery screen in `flow`.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {{ email_or_username?: string }} [opts] Prefills the field.
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
export function pushRecoverPasswordScreen (flow, { email_or_username = '' } = {}) {
|
||||
const id = `auth-recover-${window.uuidv4?.() ?? Date.now()}`;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('recover_password', [], false));
|
||||
h += '<form class="auth-form pass-recovery-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += formFieldHtml({
|
||||
name: 'email_or_username',
|
||||
label: i18n('email_or_username'),
|
||||
value: email_or_username,
|
||||
attrs: { autocomplete: 'username', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false' },
|
||||
}, id);
|
||||
h += submitButtonHtml(i18n('send_password_recovery_email', [], false), 'send-recovery-email');
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-dialog-footer auth-footer">';
|
||||
h += `<button type="button" class="auth-link auth-to-login">${i18n('log_in')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
const $screen = flow.push({ name: 'recover-password', html: h });
|
||||
const $form = $screen.find('.pass-recovery-form');
|
||||
const $input = $form.find('[name="email_or_username"]');
|
||||
wireAuthHead($screen);
|
||||
|
||||
$screen.find('.auth-to-login').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('login') ) pushLoginScreen(flow, { email_or_username: String($input.val() ?? '').trim() });
|
||||
});
|
||||
|
||||
$form.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ( flow.isBusy() ) return;
|
||||
const value = String($input.val() ?? '').trim();
|
||||
if ( ! value ) {
|
||||
setAuthStatus($screen, i18n('login_email_username_required', [], false), { field: '[name="email_or_username"]' });
|
||||
return;
|
||||
}
|
||||
setAuthStatus($screen, '');
|
||||
flow.setBusy(true);
|
||||
setFormBusy($form, true);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: `${window.api_origin}/send-pass-recovery-email`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(window.is_email(value) ? { email: value } : { username: value }),
|
||||
statusCode: {
|
||||
401: (xhr) => window.handle401(xhr),
|
||||
},
|
||||
success: resolve,
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
} catch ( err ) {
|
||||
flow.setBusy(false);
|
||||
setFormBusy($form, false);
|
||||
setAuthStatus($screen, errorMessage(err), { field: '[name="email_or_username"]' });
|
||||
return;
|
||||
}
|
||||
flow.setBusy(false);
|
||||
|
||||
// The form gives way to what the server said happened; the way back
|
||||
// to the login stays below it.
|
||||
$form.replaceWith('<div class="dashboard-form-done auth-done" role="status">'
|
||||
+ `${SENT_ICON}<p>${html_encode(res?.message ?? '')}</p></div>`);
|
||||
$screen.find('.auth-to-login').get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return $screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the account whose password to reset, on its own.
|
||||
*
|
||||
* @param {{ page?: boolean, dismissable?: boolean }} [options]
|
||||
* @returns {Promise<unknown>} Settles only if the user dismisses the card or
|
||||
* signs in from it without the page reloading.
|
||||
*/
|
||||
export default function UIAuthRecoverPassword (options = {}) {
|
||||
const joined = joinAuthFlow();
|
||||
if ( joined ) return joined;
|
||||
const flow = openAuthFlow({ page: options.page, dismissable: options.dismissable });
|
||||
pushRecoverPasswordScreen(flow);
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The signup screen: the identity provider buttons, the email form behind
|
||||
// them, and the account checks (email, phone, card) a new account may have
|
||||
// to pass once created.
|
||||
|
||||
import check_password_strength from '../../helpers/checkPasswordStrength.js';
|
||||
import { bonusRequirementsKey, checkSignupBonusCode } from '../../helpers/signupBonusCode.js';
|
||||
import { bindPasswordReveal, formFieldHtml } from '../Dashboard/UIDashboardFormDialog.js';
|
||||
import UIWindowCardVerificationRequired from '../UIWindowCardVerificationRequired.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UIWindowEmailConfirmationRequired.js';
|
||||
import UIWindowPhoneVerificationRequired from '../UIWindowPhoneVerificationRequired.js';
|
||||
import openAuthFlow, {
|
||||
authHeadHtml,
|
||||
joinAuthFlow,
|
||||
navigateAfterSignIn,
|
||||
setAuthStatus,
|
||||
setFormBusy,
|
||||
submitButtonHtml,
|
||||
wireAuthHead,
|
||||
} from './authFlow.js';
|
||||
import { renderProviderButtons } from './authProviders.js';
|
||||
import { pushLoginScreen } from './UIAuthLogin.js';
|
||||
|
||||
const SIGNUP_TIMEOUT_MS = 30000;
|
||||
const TURNSTILE_POLL_MS = 100;
|
||||
|
||||
/**
|
||||
* @typedef {object} SignupScreenOptions
|
||||
* @property {string} [username] Prefills the field.
|
||||
* @property {string} [email] Prefills the field.
|
||||
* @property {string} [authError] Shown when the screen opens.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What a checked bonus code grants, above the form and the provider buttons.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
* @param {{ valid: boolean, display?: { title: string, description: string }, requirements?: object } | null} result
|
||||
*/
|
||||
const renderBonusNotice = ($screen, result) => {
|
||||
const $notice = $screen.find('.signup-bonus-notice');
|
||||
if ( ! result ) {
|
||||
$notice.prop('hidden', true).empty();
|
||||
return;
|
||||
}
|
||||
if ( ! result.valid ) {
|
||||
$notice.addClass('signup-bonus-notice-invalid')
|
||||
.html(`<p>${i18n('signup_bonus_code_invalid_retry')}</p>`)
|
||||
.prop('hidden', false);
|
||||
return;
|
||||
}
|
||||
const requirements_key = bonusRequirementsKey(result.requirements);
|
||||
let h = `<strong>${html_encode(result.display.title)}</strong>`;
|
||||
h += `<p>${html_encode(result.display.description)}</p>`;
|
||||
if ( requirements_key ) h += `<p class="signup-bonus-requirements">${i18n(requirements_key)}</p>`;
|
||||
$notice.removeClass('signup-bonus-notice-invalid').html(h).prop('hidden', false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Covers the page with the reason signups are refused here.
|
||||
*
|
||||
* @param {string} message Plain text.
|
||||
*/
|
||||
const showSignupBlocked = (message) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('signup-blocked-overlay');
|
||||
overlay.innerHTML = `
|
||||
<div class="signup-blocked-content">
|
||||
<img src="${html_encode(window.icons['logo.svg'] || window.icons['logo-white.svg'] || '')}" style="width:64px;margin-bottom:24px;" alt="" />
|
||||
<p>${html_encode(message || 'Signup Not Allowed')}</p>
|
||||
<p>If you already have an account, try <a href="/action/login">logging in</a>. Otherwise, contact <a href="mailto:[email protected]">[email protected]</a> for assistance.</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the checks a new account has to pass, in the server's order (see
|
||||
* assertVerifiedAccount): email → phone (SMS) → card, or card → phone →
|
||||
* email when the account clears email last.
|
||||
*
|
||||
* @param {object} user The new account, as the signup answered.
|
||||
* @param {boolean} show_email Ask for the emailed code here.
|
||||
* @param {boolean} reload_on_success
|
||||
* @param {boolean} page Signup was a page, so the checks cover it too
|
||||
* rather than float over whatever was under it.
|
||||
* @returns {Promise<boolean>} Whether the email was confirmed (true when not asked).
|
||||
*/
|
||||
const runVerificationGates = async (user, show_email, reload_on_success, page) => {
|
||||
const window_options = page ? { cover_page: true, has_head: false } : {};
|
||||
let email_verified = true;
|
||||
const emailGate = async () => {
|
||||
if ( ! show_email ) return;
|
||||
// Its ✕ would close it without an answer, leaving the caller
|
||||
// waiting on a card that is already gone.
|
||||
email_verified = await UIWindowEmailConfirmationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
reload_on_success,
|
||||
window_options,
|
||||
});
|
||||
};
|
||||
// A user the SMS path keeps failing may take the card fallback instead;
|
||||
// that resolves 'card' and clears the card gate along with the phone
|
||||
// gate (see UIWindowPhoneVerificationRequired).
|
||||
let card_cleared_by_fallback = false;
|
||||
const phoneGate = async () => {
|
||||
if ( ! user?.requires_phone_verification ) return;
|
||||
let phone_ok = false;
|
||||
do {
|
||||
phone_ok = await UIWindowPhoneVerificationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
window_options,
|
||||
});
|
||||
} while ( ! phone_ok );
|
||||
card_cleared_by_fallback = phone_ok === 'card';
|
||||
};
|
||||
const cardGate = async () => {
|
||||
if ( ! user?.requires_card_verification || card_cleared_by_fallback ) return;
|
||||
let card_ok = false;
|
||||
do {
|
||||
card_ok = await UIWindowCardVerificationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
window_options,
|
||||
});
|
||||
} while ( ! card_ok );
|
||||
};
|
||||
if ( user?.email_gate_last ) {
|
||||
await cardGate();
|
||||
await phoneGate();
|
||||
await emailGate();
|
||||
} else {
|
||||
await emailGate();
|
||||
await phoneGate();
|
||||
await cardGate();
|
||||
}
|
||||
return email_verified;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where to go after a signup that reloads. Signup can interrupt a direct app
|
||||
* landing (the login cover page's "Sign up", the account picker's "Create
|
||||
* Account", the must_login_or_signup fallback); landing on '/' afterwards
|
||||
* would drop the app the URL asked for, so app landings are kept and the
|
||||
* reloaded boot replays them. The query string is left behind, as login
|
||||
* does, so no credentials stay in it.
|
||||
*
|
||||
* @param {string | undefined} redirect_url
|
||||
* @returns {string}
|
||||
*/
|
||||
export const signupRedirectUrl = (redirect_url) => {
|
||||
if ( redirect_url ) return redirect_url;
|
||||
const on_app_landing = /^\/(?:desktop\/)?app\/[^/]+\/?$/.test(window.location.pathname);
|
||||
return on_app_landing ? window.location.origin + window.location.pathname : '/';
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the signup screen in `flow`.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
* @param {SignupScreenOptions} [options]
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
export function pushSignupScreen (flow, options = {}) {
|
||||
const id = `auth-signup-${window.uuidv4?.() ?? Date.now()}`;
|
||||
const turnstile_key = window.gui_params?.turnstileSiteKey;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('create_free_account', [], false));
|
||||
// Above the provider buttons, not in the form: the form hides behind
|
||||
// them, and an identity provider's refusal shows here too.
|
||||
h += '<div class="dashboard-form-status auth-screen-status" role="status" aria-live="polite"></div>';
|
||||
h += '<div class="signup-bonus-notice" hidden></div>';
|
||||
h += `<p class="signup-oidc-only-notice" hidden>${i18n('signup_oidc_only')}</p>`;
|
||||
h += '<div class="auth-providers oidc-providers-wrapper" hidden>';
|
||||
h += '<div class="auth-provider-list"></div>';
|
||||
h += '</div>';
|
||||
h += '<div class="signup-with-email-wrapper" hidden>';
|
||||
h += `<button type="button" class="dashboard-dialog-btn auth-provider-btn signup-with-email-btn">${i18n('sign_up_with_email')}</button>`;
|
||||
h += '</div>';
|
||||
h += '<form class="auth-form signup-form" novalidate>';
|
||||
h += formFieldHtml({
|
||||
name: 'username',
|
||||
label: i18n('username'),
|
||||
value: options.username ?? '',
|
||||
attrs: { autocomplete: 'username', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false', 'data-gramm_editor': 'false' },
|
||||
}, id);
|
||||
h += formFieldHtml({
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: i18n('email'),
|
||||
value: options.email ?? '',
|
||||
attrs: { autocomplete: 'email', inputmode: 'email', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false', 'data-gramm_editor': 'false' },
|
||||
}, id);
|
||||
h += formFieldHtml({
|
||||
name: 'password',
|
||||
type: 'password',
|
||||
label: i18n('password'),
|
||||
attrs: { autocomplete: 'new-password' },
|
||||
}, id);
|
||||
h += formFieldHtml({
|
||||
name: 'confirm_password',
|
||||
type: 'password',
|
||||
label: i18n('signup_confirm_password'),
|
||||
attrs: { autocomplete: 'new-password' },
|
||||
}, id);
|
||||
// Bot trap: the server ignores a request that fills it in.
|
||||
h += '<input type="text" name="p102xyzname" class="p102xyzname" value="" tabindex="-1" autocomplete="off" aria-hidden="true">';
|
||||
if ( turnstile_key ) {
|
||||
h += `<div class="auth-turnstile"><div class="cf-turnstile" data-sitekey="${html_encode(turnstile_key)}" data-appearance="interaction-only"></div></div>`;
|
||||
}
|
||||
h += submitButtonHtml(i18n('create_free_account', [], false), 'signup-btn');
|
||||
h += '</form>';
|
||||
h += `<p class="signup-terms">${i18n('tos_fineprint', [], false)}</p>`;
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-dialog-footer auth-footer">';
|
||||
h += `<button type="button" class="auth-link auth-to-login">${i18n('log_in')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
const $screen = flow.push({
|
||||
name: 'signup',
|
||||
html: h,
|
||||
autofocus: ! window.disable_signup_autofocus,
|
||||
});
|
||||
const $form = $screen.find('.signup-form');
|
||||
const field = (name) => $form.find(`[name="${name}"]`);
|
||||
const $turnstile = $form.find('.cf-turnstile');
|
||||
let turnstile_widget = null;
|
||||
let offers_providers = false;
|
||||
|
||||
wireAuthHead($screen);
|
||||
bindPasswordReveal($form);
|
||||
if ( options.authError ) setAuthStatus($screen, options.authError);
|
||||
|
||||
$form.on('input', '.dashboard-form-input', function () {
|
||||
$(this).removeAttr('aria-invalid');
|
||||
});
|
||||
|
||||
const showForm = () => {
|
||||
$form.prop('hidden', false);
|
||||
$screen.find('.signup-with-email-wrapper').prop('hidden', true);
|
||||
};
|
||||
|
||||
// Form signups are closed here, so the providers are the only way in.
|
||||
const showOidcOnly = () => {
|
||||
$form.prop('hidden', true);
|
||||
$screen.find('.signup-with-email-wrapper').prop('hidden', true);
|
||||
$screen.find('.oidc-providers-wrapper').prop('hidden', false);
|
||||
$screen.find('.signup-oidc-only-notice').prop('hidden', false);
|
||||
};
|
||||
|
||||
// Turnstile renders into the form once its script has loaded.
|
||||
const initTurnstile = () => {
|
||||
if ( ! turnstile_key || ! $turnstile.length || flow.isClosed() ) return;
|
||||
if ( ! window.turnstile ) {
|
||||
setTimeout(initTurnstile, TURNSTILE_POLL_MS);
|
||||
return;
|
||||
}
|
||||
turnstile_widget = window.turnstile.render($turnstile.get(0), {
|
||||
sitekey: turnstile_key,
|
||||
callback: (token) => {
|
||||
$turnstile.attr('data-token', token).addClass('captcha-completed');
|
||||
},
|
||||
'expired-callback': () => {
|
||||
$turnstile.removeAttr('data-token').removeClass('captcha-completed');
|
||||
},
|
||||
});
|
||||
};
|
||||
initTurnstile();
|
||||
|
||||
if ( window.signup_bonus_code ) {
|
||||
(async () => {
|
||||
let fingerprint = null;
|
||||
try {
|
||||
fingerprint = await window.getDeviceFingerprint?.();
|
||||
} catch {
|
||||
// The check works without device signals.
|
||||
}
|
||||
const result = await checkSignupBonusCode(window.signup_bonus_code, {
|
||||
origin: window.gui_origin,
|
||||
fingerprint,
|
||||
});
|
||||
// Unknown (null) keeps the code; signup has the final say.
|
||||
if ( result && ! result.valid ) window.signup_bonus_code = null;
|
||||
renderBonusNotice($screen, result);
|
||||
})();
|
||||
}
|
||||
|
||||
renderProviderButtons($screen.find('.auth-provider-list'), {
|
||||
kind: 'signup',
|
||||
referrer: () => flow.options.referrer ?? window.referrerStr,
|
||||
}).then((offered) => {
|
||||
if ( ! offered || flow.isClosed() ) return;
|
||||
offers_providers = true;
|
||||
$screen.find('.oidc-providers-wrapper').prop('hidden', false);
|
||||
if ( offered.signupOidcOnly ) {
|
||||
showOidcOnly();
|
||||
return;
|
||||
}
|
||||
// The email form waits behind its own button, unless the user has
|
||||
// already typed into it.
|
||||
const started = $form.find('.dashboard-form-input').filter(function () {
|
||||
return !! $(this).val();
|
||||
}).length > 0;
|
||||
if ( started ) return;
|
||||
const had_focus = $form.get(0).contains(document.activeElement);
|
||||
$form.prop('hidden', true);
|
||||
$screen.find('.signup-with-email-wrapper').prop('hidden', false);
|
||||
if ( had_focus ) flow.focus();
|
||||
});
|
||||
|
||||
$screen.find('.signup-with-email-btn').on('click', () => {
|
||||
showForm();
|
||||
field('username').get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
$screen.find('.auth-to-login').on('click', () => {
|
||||
if ( flow.isBusy() ) return;
|
||||
if ( ! flow.goTo('login') ) pushLoginScreen(flow);
|
||||
});
|
||||
|
||||
const resetTurnstile = () => {
|
||||
try {
|
||||
if ( window.turnstile && turnstile_widget !== null ) {
|
||||
window.turnstile.reset(turnstile_widget);
|
||||
$turnstile.removeAttr('data-token').removeClass('captcha-completed');
|
||||
}
|
||||
} catch ( e ) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (text, field_name) => {
|
||||
setAuthStatus($screen, text, field_name ? { field: `[name="${field_name}"]` } : {});
|
||||
};
|
||||
|
||||
$form.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ( flow.isBusy() ) return;
|
||||
|
||||
const username = String(field('username').val() ?? '').trim();
|
||||
const email = String(field('email').val() ?? '').trim();
|
||||
const password = String(field('password').val() ?? '');
|
||||
const confirm_password = String(field('confirm_password').val() ?? '');
|
||||
|
||||
if ( ! username ) return fail(i18n('username_required', [], false), 'username');
|
||||
if ( ! email ) return fail(i18n('email_required', [], false), 'email');
|
||||
if ( ! window.is_email(email) ) return fail(i18n('email_invalid', [], false), 'email');
|
||||
if ( ! password ) return fail(i18n('password_required', [], false), 'password');
|
||||
if ( ! check_password_strength(password).overallPass ) return fail(i18n('password_strength_error', [], false), 'password');
|
||||
if ( ! confirm_password ) return fail(i18n('confirm_password_required', [], false), 'confirm_password');
|
||||
if ( password !== confirm_password ) return fail(i18n('passwords_do_not_match', [], false), 'confirm_password');
|
||||
|
||||
let turnstile_token = null;
|
||||
if ( window.turnstile && turnstile_key ) {
|
||||
turnstile_token = $turnstile.attr('data-token');
|
||||
if ( ! turnstile_token ) return fail(i18n('captcha_required', [], false));
|
||||
}
|
||||
setAuthStatus($screen, '');
|
||||
|
||||
flow.setBusy(true);
|
||||
setFormBusy($form, true);
|
||||
|
||||
// A device signal for abuse prevention; signup never waits on or
|
||||
// fails because of it.
|
||||
let fingerprint = null;
|
||||
try {
|
||||
fingerprint = await window.getDeviceFingerprint?.();
|
||||
} catch {
|
||||
// Omitted.
|
||||
}
|
||||
|
||||
const request = {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
referrer: flow.options.referrer ?? window.referrerStr,
|
||||
send_confirmation_code: flow.options.send_confirmation_code,
|
||||
p102xyzname: String($form.find('.p102xyzname').val() ?? ''),
|
||||
'cf-turnstile-response': turnstile_token,
|
||||
};
|
||||
if ( fingerprint ) request.fingerprint = fingerprint;
|
||||
if ( window.signup_bonus_code ) request.bonusCode = window.signup_bonus_code;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
url: `${window.gui_origin}/signup`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: window.custom_headers ?? {},
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(request),
|
||||
success: resolve,
|
||||
error: reject,
|
||||
timeout: SIGNUP_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
} catch ( err ) {
|
||||
setFormBusy($form, false);
|
||||
flow.setBusy(false);
|
||||
resetTurnstile();
|
||||
handleError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.update_auth_data(data.token, data.user);
|
||||
} catch ( err ) {
|
||||
setFormBusy($form, false);
|
||||
flow.setBusy(false);
|
||||
fail(err?.message || i18n('signup_error', [], false));
|
||||
return;
|
||||
}
|
||||
|
||||
const reload_on_success = flow.options.reload_on_success;
|
||||
const show_email = ! reload_on_success
|
||||
&& !! (flow.options.send_confirmation_code || data.user?.requires_email_confirmation);
|
||||
let email_verified = true;
|
||||
if ( data.user?.requires_phone_verification || data.user?.requires_card_verification || show_email ) {
|
||||
// The checks are windows of their own; the card makes way for them.
|
||||
flow.close();
|
||||
email_verified = await runVerificationGates(data.user, show_email, reload_on_success, flow.page);
|
||||
}
|
||||
|
||||
if ( reload_on_success ) {
|
||||
navigateAfterSignIn(signupRedirectUrl(flow.options.redirect_url));
|
||||
return;
|
||||
}
|
||||
flow.finish(email_verified);
|
||||
});
|
||||
|
||||
const handleError = (err) => {
|
||||
const error_text = err?.responseText || '';
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(error_text);
|
||||
} catch {
|
||||
// Not JSON.
|
||||
}
|
||||
|
||||
if ( json?.code === 'signup_blocked' ) {
|
||||
showSignupBlocked(json.message);
|
||||
return;
|
||||
}
|
||||
// Form signups closed after this screen loaded. Without provider
|
||||
// buttons (some popups), the server's message below says so instead.
|
||||
if ( json?.code === 'signup_oidc_only' && offers_providers ) {
|
||||
showOidcOnly();
|
||||
return;
|
||||
}
|
||||
if ( json?.code === 'bonus_code_invalid' ) {
|
||||
// The next attempt signs up without it; the offer no longer applies.
|
||||
window.signup_bonus_code = null;
|
||||
renderBonusNotice($screen, null);
|
||||
fail(i18n('signup_bonus_code_invalid_retry', [], false));
|
||||
return;
|
||||
}
|
||||
if ( json?.code === 'response_timeout' || err?.statusText === 'timeout' || error_text.includes('timeout') ) {
|
||||
fail(i18n('server_timeout', [], false));
|
||||
return;
|
||||
}
|
||||
if ( json?.message ) {
|
||||
fail(String(json.message));
|
||||
return;
|
||||
}
|
||||
fail(error_text || i18n('signup_error', [], false));
|
||||
};
|
||||
|
||||
return $screen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the user to create an account.
|
||||
*
|
||||
* @param {Partial<import('./authFlow.js').AuthFlowOptions> & SignupScreenOptions & {
|
||||
* page?: boolean, dismissable?: boolean, closeOnBackdrop?: boolean,
|
||||
* }} [options] `page`, `dismissable` and `closeOnBackdrop` as in `openAuthFlow`.
|
||||
* @returns {Promise<boolean>} True once signed up (or false if a requested
|
||||
* email confirmation wasn't completed), unless the page reloads first;
|
||||
* false when the user dismisses it. Joins an auth flow that is already
|
||||
* open instead of opening another.
|
||||
*/
|
||||
export default function UIAuthSignup (options = {}) {
|
||||
const joined = joinAuthFlow();
|
||||
if ( joined ) return joined;
|
||||
const flow = openAuthFlow({
|
||||
page: options.page,
|
||||
dismissable: options.dismissable,
|
||||
closeOnBackdrop: options.closeOnBackdrop,
|
||||
options,
|
||||
});
|
||||
pushSignupScreen(flow, options);
|
||||
return flow.result;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reply, resetAuthGlobals, screen, tick } from './authTestSetup.js';
|
||||
|
||||
const gates = vi.hoisted(() => ({ email: vi.fn(), phone: vi.fn(), card: vi.fn() }));
|
||||
vi.mock('../UIWindowEmailConfirmationRequired.js', () => ({ default: gates.email }));
|
||||
vi.mock('../UIWindowPhoneVerificationRequired.js', () => ({ default: gates.phone }));
|
||||
vi.mock('../UIWindowCardVerificationRequired.js', () => ({ default: gates.card }));
|
||||
|
||||
const { activeAuthFlow } = await import('./authFlow.js');
|
||||
const { default: UIAuthSignup, signupRedirectUrl } = await import('./UIAuthSignup.js');
|
||||
const { default: UIAuthAccountPicker } = await import('./UIAuthAccountPicker.js');
|
||||
const { default: UIAuthRecoverPassword } = await import('./UIAuthRecoverPassword.js');
|
||||
|
||||
const providers = (body) => {
|
||||
globalThis.fetch = vi.fn(async () => reply(200, body));
|
||||
};
|
||||
|
||||
const fillForm = () => {
|
||||
$('[name="username"]').val('someone');
|
||||
$('[name="email"]').val('[email protected]');
|
||||
$('[name="password"]').val('a-Strong-pass-1');
|
||||
$('[name="confirm_password"]').val('a-Strong-pass-1');
|
||||
};
|
||||
|
||||
const captureAjax = () => {
|
||||
const calls = [];
|
||||
vi.spyOn($, 'ajax').mockImplementation((opts) => {
|
||||
calls.push(opts);
|
||||
});
|
||||
return calls;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthGlobals();
|
||||
gates.email.mockReset();
|
||||
gates.phone.mockReset();
|
||||
gates.card.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
activeAuthFlow()?.finish(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('signup when only OIDC signups are open', () => {
|
||||
const open = async () => {
|
||||
void UIAuthSignup({ reload_on_success: false });
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
};
|
||||
|
||||
it('offers the email form behind a button normally', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
await open();
|
||||
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(false);
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
expect($('.signup-oidc-only-notice').prop('hidden')).toBe(true);
|
||||
$('.signup-with-email-btn').trigger('click');
|
||||
expect($('.signup-form').prop('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the email form and says why', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: true });
|
||||
await open();
|
||||
expect($('.signup-oidc-only-notice').prop('hidden')).toBe(false);
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('switches to the providers when a form signup is refused for it', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
await open();
|
||||
const calls = captureAjax();
|
||||
$('.signup-with-email-btn').trigger('click');
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].error({ responseText: JSON.stringify({ code: 'signup_oidc_only', message: 'closed' }) });
|
||||
await tick();
|
||||
expect($('.signup-oidc-only-notice').prop('hidden')).toBe(false);
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])('keeps an error it opened with in view (OIDC only: %s)', async (signupOidcOnly) => {
|
||||
providers({ providers: ['google'], signupOidcOnly });
|
||||
void UIAuthSignup({ authError: 'Your account could not be created.' });
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
const $status = screen().find('.dashboard-form-status');
|
||||
expect($status.text()).toBe('Your account could not be created.');
|
||||
expect($status.closest('[hidden]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves a form the user already typed into', async () => {
|
||||
let answer;
|
||||
globalThis.fetch = vi.fn(() => new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
void UIAuthSignup();
|
||||
$('[name="username"]').val('started');
|
||||
answer(reply(200, { providers: ['google'] }));
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
expect($('.signup-form').prop('hidden')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signup form', () => {
|
||||
it.each([
|
||||
[{}, 'username_required', 'username'],
|
||||
[{ username: 'a' }, 'email_required', 'email'],
|
||||
[{ username: 'a', email: 'nope' }, 'email_invalid', 'email'],
|
||||
[{ username: 'a', email: '[email protected]' }, 'password_required', 'password'],
|
||||
[{ username: 'a', email: '[email protected]', password: 'weak' }, 'password_strength_error', 'password'],
|
||||
[{ username: 'a', email: '[email protected]', password: 'a-Strong-pass-1' }, 'confirm_password_required', 'confirm_password'],
|
||||
[{ username: 'a', email: '[email protected]', password: 'a-Strong-pass-1', confirm_password: 'other' }, 'passwords_do_not_match', 'confirm_password'],
|
||||
])('checks %j before sending', (values, message, field) => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
for ( const [name, value] of Object.entries(values) ) $(`[name="${name}"]`).val(value);
|
||||
$('.signup-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe(message);
|
||||
expect($(`[name="${field}"]`).attr('aria-invalid')).toBe('true');
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('asks for the CAPTCHA when Turnstile is on', async () => {
|
||||
window.gui_params = { turnstileSiteKey: 'key' };
|
||||
window.turnstile = { render: vi.fn(() => 'widget'), reset: vi.fn() };
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
expect(window.turnstile.render).toHaveBeenCalledWith($('.cf-turnstile').get(0), expect.objectContaining({ sitekey: 'key' }));
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('captcha_required');
|
||||
window.turnstile.render.mock.calls[0][1].callback('tok');
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(JSON.parse(calls[0].data)['cf-turnstile-response']).toBe('tok');
|
||||
calls[0].error({ responseText: '{"message":"no"}' });
|
||||
await tick();
|
||||
expect(window.turnstile.reset).toHaveBeenCalledWith('widget');
|
||||
});
|
||||
|
||||
it('sends the honeypot, referrer, bonus code and confirmation setting', async () => {
|
||||
window.signup_bonus_code = 'BONUS';
|
||||
globalThis.fetch = vi.fn(async () => reply(200, { valid: true, display: { title: 'T', description: 'D' } }));
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup({ referrer: 'app-x', send_confirmation_code: true });
|
||||
fillForm();
|
||||
$('.p102xyzname').val('bot');
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
expect(JSON.parse(calls[0].data)).toMatchObject({
|
||||
username: 'someone',
|
||||
email: '[email protected]',
|
||||
referrer: 'app-x',
|
||||
send_confirmation_code: true,
|
||||
p102xyzname: 'bot',
|
||||
bonusCode: 'BONUS',
|
||||
});
|
||||
expect(calls[0].timeout).toBe(30000);
|
||||
});
|
||||
|
||||
it('drops a refused bonus code and says so', async () => {
|
||||
window.signup_bonus_code = 'BAD';
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].error({ responseText: JSON.stringify({ code: 'bonus_code_invalid' }) });
|
||||
await tick();
|
||||
expect(window.signup_bonus_code).toBeNull();
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('signup_bonus_code_invalid_retry');
|
||||
expect($('.signup-btn').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('puts focus back in the form after the server refuses, so Enter can retry', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
fillForm();
|
||||
$('[name="confirm_password"]').get(0).focus();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].error({ responseText: '{"message":"This username is not available."}' });
|
||||
await tick();
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('This username is not available.');
|
||||
expect(document.activeElement).toBe($('[name="confirm_password"]').get(0));
|
||||
});
|
||||
|
||||
it('covers the page when signups are blocked here', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthSignup();
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].error({ responseText: JSON.stringify({ code: 'signup_blocked', message: '<i>No</i>' }) });
|
||||
await tick();
|
||||
expect($('.signup-blocked-overlay').text()).toContain('<i>No</i>');
|
||||
expect($('.signup-blocked-overlay i')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('runs the account checks after the card makes way, then settles', async () => {
|
||||
gates.email.mockResolvedValue(true);
|
||||
gates.phone.mockResolvedValueOnce(false).mockResolvedValueOnce('card');
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthSignup({ reload_on_success: false, send_confirmation_code: true });
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].success({ token: 't', user: { requires_phone_verification: true, requires_card_verification: true } });
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect($('.auth-dialog-overlay.dashboard-dialog-show')).toHaveLength(0);
|
||||
// email → phone (retried until passed) → card, which the phone's card fallback cleared.
|
||||
expect(gates.email).toHaveBeenCalledOnce();
|
||||
// Closing a check without an answer would strand the caller.
|
||||
for ( const gate of [gates.email, gates.phone] ) {
|
||||
expect(gate.mock.calls[0][0].show_close_button).toBe(false);
|
||||
}
|
||||
expect(gates.phone).toHaveBeenCalledTimes(2);
|
||||
expect(gates.card).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, { cover_page: true, has_head: false }],
|
||||
[false, {}],
|
||||
])('covers the page with the account checks when signup was a page (%s)', async (page, window_options) => {
|
||||
gates.email.mockResolvedValue(true);
|
||||
gates.phone.mockResolvedValue(true);
|
||||
gates.card.mockResolvedValue(true);
|
||||
const calls = captureAjax();
|
||||
const result = UIAuthSignup({ page, reload_on_success: false, send_confirmation_code: true });
|
||||
fillForm();
|
||||
$('.signup-form').trigger('submit');
|
||||
await vi.waitFor(() => expect(calls).toHaveLength(1));
|
||||
calls[0].success({ token: 't', user: { requires_phone_verification: true, requires_card_verification: true } });
|
||||
await result;
|
||||
for ( const gate of [gates.email, gates.phone, gates.card] ) {
|
||||
expect(gate.mock.calls[0][0].window_options).toEqual(window_options);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an app landing after a signup that reloads', () => {
|
||||
expect(signupRedirectUrl('/x')).toBe('/x');
|
||||
expect(signupRedirectUrl(undefined)).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('account picker', () => {
|
||||
beforeEach(() => {
|
||||
window.logged_in_users = [
|
||||
{ uuid: 'u1', username: 'alice', email: '[email protected]', auth_token: 'ta', profile: { picture: 'data:image/png;base64,AA' } },
|
||||
{ uuid: 'u2', username: '<b>bob</b>', auth_token: 'tb' },
|
||||
];
|
||||
});
|
||||
|
||||
it('lists the accounts, encoded, and can not be dismissed', () => {
|
||||
void UIAuthAccountPicker({ reload_on_success: false, page: true });
|
||||
expect($('.auth-account')).toHaveLength(2);
|
||||
expect($('.auth-account b')).toHaveLength(0);
|
||||
expect($('.auth-account-avatar img').attr('src')).toBe('data:image/png;base64,AA');
|
||||
expect($('.dashboard-dialog-close')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('signs in as the picked account', async () => {
|
||||
const result = UIAuthAccountPicker({ reload_on_success: false });
|
||||
$('.auth-account[data-uuid="u2"]').trigger('click');
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.update_auth_data).toHaveBeenCalledWith('tb', window.logged_in_users[1]);
|
||||
});
|
||||
|
||||
it('logs in to another account with a way back to the list', async () => {
|
||||
captureAjax();
|
||||
void UIAuthAccountPicker({ reload_on_success: false });
|
||||
$('.auth-to-login').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('login');
|
||||
$('.auth-dialog-back').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('accounts');
|
||||
$('.auth-to-signup').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('signup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('password recovery', () => {
|
||||
it('asks for the account, then shows what the server said', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthRecoverPassword({ page: true });
|
||||
$('.pass-recovery-form').trigger('submit');
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('login_email_username_required');
|
||||
$('[name="email_or_username"]').val('[email protected]');
|
||||
$('.pass-recovery-form').trigger('submit');
|
||||
expect(calls[0].url).toBe('https://api.puter.test/send-pass-recovery-email');
|
||||
expect(JSON.parse(calls[0].data)).toEqual({ email: '[email protected]' });
|
||||
calls[0].success({ message: 'Sent <b>it</b>' });
|
||||
await tick();
|
||||
expect($('.auth-done').text()).toBe('Sent <b>it</b>');
|
||||
expect($('.pass-recovery-form')).toHaveLength(0);
|
||||
// The page has a way on to the login.
|
||||
$('.auth-to-login').trigger('click');
|
||||
expect(screen().attr('data-screen')).toBe('login');
|
||||
});
|
||||
|
||||
it('shows a refusal on the form', async () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthRecoverPassword();
|
||||
$('[name="email_or_username"]').val('someone');
|
||||
$('.pass-recovery-form').trigger('submit');
|
||||
expect(JSON.parse(calls[0].data)).toEqual({ username: 'someone' });
|
||||
calls[0].error({ responseText: JSON.stringify({ message: 'Too many' }) });
|
||||
await tick();
|
||||
expect(screen().find('.dashboard-form-status').text()).toBe('Too many');
|
||||
expect($('[name="email_or_username"]').prop('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The card that sign-in, sign-up, the account picker, password recovery and
|
||||
// the second factor share: a dashboard dialog whose screens replace each
|
||||
// other in place, with a Back button, instead of opening windows over each
|
||||
// other. One flow is open at a time.
|
||||
//
|
||||
// Two presentations. A dialog dims what is under it, and docks as a bottom
|
||||
// sheet on phones. A page (a cover page, or any sign-in popup) has nothing
|
||||
// to go back to: an opaque page under a centered card, and the whole screen
|
||||
// on phones and in popups.
|
||||
|
||||
import { isAttestedOrigin } from '../../helpers/attestedOrigin.js';
|
||||
import { authLogoHeader, wireAuthLogoHeader } from '../../helpers/authLogoHeader.js';
|
||||
import { get_auth_redirect_url } from '../../helpers/authRedirect.js';
|
||||
import { syncSessionCookie } from '../../util/sessionAuth.js';
|
||||
import { isTouchPrimaryDevice } from '../Dashboard/ContextMenu/ContextMenu.js';
|
||||
import mountDashboardOverlay from '../Dashboard/dashboardOverlay.js';
|
||||
import { ICONS, SVG } from '../Dashboard/UIDashboardDialog.js';
|
||||
|
||||
const BACK_ICON = SVG('<path d="M15 18l-6-6 6-6"/>');
|
||||
|
||||
/** @type {AuthFlow | null} */
|
||||
let active_flow = null;
|
||||
|
||||
/**
|
||||
* @typedef {object} AuthScreen
|
||||
* @property {string} name Matched by `goTo`.
|
||||
* @property {string} html The screen's markup. A `.auth-title` names the
|
||||
* dialog while the screen shows.
|
||||
* @property {boolean} [autofocus] Focus the first field on show; default true.
|
||||
* @property {() => void} [onBack] Makes Back available on the first screen,
|
||||
* where it runs this instead of returning to an earlier screen.
|
||||
* @property {() => void} [onLeave] Runs when the screen is taken off the
|
||||
* stack: by Back, `goTo`, or the flow closing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What every screen of a flow does once the user is signed in. Set from the
|
||||
* options of the call that opened the flow; screens it leads to share them.
|
||||
*
|
||||
* @typedef {object} AuthFlowOptions
|
||||
* @property {boolean} reload_on_success Navigate to `redirect_url` on success,
|
||||
* rather than settle the flow with `true`.
|
||||
* @property {string} [redirect_url] Defaults to the page the flow started on.
|
||||
* @property {string} [referrer] Sent with a signup, for user acquisition.
|
||||
* @property {boolean} send_confirmation_code Ask a new account to confirm its
|
||||
* email with a code.
|
||||
* @property {boolean} show_signup_button Offer to create an account from
|
||||
* the login screen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Partial<AuthFlowOptions>} [options]
|
||||
* @returns {AuthFlowOptions}
|
||||
*/
|
||||
export const authFlowOptions = (options = {}) => ({
|
||||
reload_on_success: options.reload_on_success ?? true,
|
||||
// Stay on the page the flow started from (e.g. an /app/<name> landing);
|
||||
// standalone auth pages go to the root dashboard.
|
||||
redirect_url: options.redirect_url === undefined ? get_auth_redirect_url() : options.redirect_url,
|
||||
referrer: options.referrer,
|
||||
send_confirmation_code: options.send_confirmation_code ?? false,
|
||||
show_signup_button: options.show_signup_button ?? true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Leaves for `url` once signed in. A URL that differs from this page's only
|
||||
* in its hash (a dashboard `#tab`) would just scroll, never reload, so the
|
||||
* page reloads at it instead.
|
||||
*
|
||||
* @param {string} url
|
||||
*/
|
||||
export const navigateAfterSignIn = (url) => {
|
||||
window.onbeforeunload = null;
|
||||
const target = new URL(url, window.location.href);
|
||||
const here = new URL(window.location.href);
|
||||
target.hash = '';
|
||||
here.hash = '';
|
||||
if ( target.href !== here.href ) {
|
||||
window.location.replace(url);
|
||||
return;
|
||||
}
|
||||
history.replaceState(history.state, '', new URL(url, window.location.href).href);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
/**
|
||||
* Hands the session cookie back to the account this page is signed in as.
|
||||
* A sign-in the server completes after the user dismissed the card has
|
||||
* already moved the cookie to the other account, and the endpoints that
|
||||
* read the cookie (password, 2FA) would act on that one.
|
||||
*/
|
||||
export const keepCurrentSession = () => {
|
||||
void syncSessionCookie({ origin: window.gui_origin || window.location.origin, authToken: window.auth_token });
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} AuthFlow
|
||||
* @property {AuthFlowOptions} options
|
||||
* @property {jQuery} $overlay
|
||||
* @property {jQuery} $dialog
|
||||
* @property {boolean} page Whether it shows as a page rather than a dialog.
|
||||
* @property {boolean} dismissable
|
||||
* @property {Promise<unknown>} result Settles once, with `settle`/`finish`'s
|
||||
* value, or `false` when the user dismisses the flow.
|
||||
* @property {(screen: AuthScreen) => jQuery} push Shows a screen over the
|
||||
* current one; returns its element.
|
||||
* @property {() => void} back
|
||||
* @property {(name: string) => boolean} goTo Returns to an earlier screen by
|
||||
* name, if there is one.
|
||||
* @property {(busy: boolean) => void} setBusy While busy, only ✕ dismisses
|
||||
* the flow and Back is disabled. Ending it returns focus to where it was,
|
||||
* once that control is enabled again.
|
||||
* @property {() => boolean} isBusy
|
||||
* @property {() => void} close Takes the card down without settling.
|
||||
* @property {(value: unknown) => void} settle
|
||||
* @property {(value: unknown) => void} finish `close`, then `settle`.
|
||||
* @property {() => boolean} isClosed
|
||||
* @property {() => void} focus Brings focus back to the current screen.
|
||||
*/
|
||||
|
||||
/** The open flow, if any. */
|
||||
export const activeAuthFlow = () => (active_flow && ! active_flow.isClosed() ? active_flow : null);
|
||||
|
||||
/**
|
||||
* The first enabled, visible field of a screen.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
* @returns {HTMLElement | undefined}
|
||||
*/
|
||||
const firstField = ($screen) => $screen
|
||||
.find('input:not([type=hidden]):not(.p102xyzname), select, textarea')
|
||||
.filter(':visible:not(:disabled):not([readonly])')
|
||||
.get(0);
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.page] Show as a page; see the top of this file.
|
||||
* Sign-in popups always are.
|
||||
* @param {boolean} [opts.dismissable] Offer ✕ and Escape; defaults to
|
||||
* `!page`.
|
||||
* @param {boolean} [opts.closeOnBackdrop] A press on the dimmed area
|
||||
* dismisses it too. Off by default: a stray click must not lose typed
|
||||
* credentials.
|
||||
* @param {Partial<AuthFlowOptions>} [opts.options]
|
||||
* @returns {AuthFlow}
|
||||
*/
|
||||
export default function openAuthFlow ({ page = false, dismissable = ! page, closeOnBackdrop = false, options } = {}) {
|
||||
// A newer flow wins; the old one's caller hears it was dismissed.
|
||||
activeAuthFlow()?.finish(false);
|
||||
|
||||
const id = `auth-${window.uuidv4?.() ?? Date.now()}`;
|
||||
const as_page = !! page || !! window.embedded_in_popup;
|
||||
|
||||
let h = '<div class="dashboard-dialog auth-dialog" role="dialog" aria-modal="true" tabindex="-1">';
|
||||
h += '<div class="auth-dialog-bar">';
|
||||
h += `<button type="button" class="auth-dialog-back" hidden aria-label="${i18n('back')}" title="${i18n('back')}">${BACK_ICON}</button>`;
|
||||
if ( dismissable ) {
|
||||
h += `<button type="button" class="dashboard-dialog-close" aria-label="${i18n('close')}" title="${i18n('close')}">${ICONS.close}</button>`;
|
||||
}
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
|
||||
let resolve;
|
||||
const result = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
let busy = false;
|
||||
/** @type {HTMLElement | null} */
|
||||
let el_busy_return = null;
|
||||
let settled = false;
|
||||
let torn_down = false;
|
||||
/** @type {Array<AuthScreen & { $el: jQuery, el_return?: Element | null }>} */
|
||||
const stack = [];
|
||||
|
||||
const teardown = () => {
|
||||
if ( torn_down ) return;
|
||||
torn_down = true;
|
||||
if ( active_flow === flow ) active_flow = null;
|
||||
// Screens waiting on the user (the second factor) hear they won't get an answer.
|
||||
while ( stack.length ) stack.pop().onLeave?.();
|
||||
};
|
||||
|
||||
const { $overlay, $dialog, close: closeOverlay, isClosed } = mountDashboardOverlay({
|
||||
html: h,
|
||||
canDismiss: (via) => dismissable
|
||||
&& (via === 'close' || (! busy && (via !== 'backdrop' || closeOnBackdrop))),
|
||||
onDismiss: () => {
|
||||
teardown();
|
||||
flow.settle(false);
|
||||
},
|
||||
});
|
||||
$overlay.addClass('auth-dialog-overlay');
|
||||
if ( as_page ) $overlay.addClass('auth-dialog-page');
|
||||
|
||||
const $back = $dialog.find('.auth-dialog-back');
|
||||
const top = () => stack[stack.length - 1];
|
||||
|
||||
const focusScreen = (screen) => {
|
||||
if ( ! screen || isClosed() ) return;
|
||||
// A keyboard popping up mid-animation on a phone hides half the
|
||||
// card; there the user taps the field they want.
|
||||
const el_field = screen.autofocus !== false && ! isTouchPrimaryDevice()
|
||||
? firstField(screen.$el) ?? screen.$el.find('.auth-provider-btn').filter(':visible').get(0)
|
||||
: undefined;
|
||||
(el_field ?? $dialog.get(0))?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
// Back and ✕ float over the top of the screen. Once it scrolls, the bar
|
||||
// under them turns opaque, so what passes beneath neither shows through
|
||||
// nor takes their taps.
|
||||
const syncScrolled = () => {
|
||||
const el_body = top()?.$el.find('.auth-body').get(0);
|
||||
$dialog.toggleClass('auth-dialog-scrolled', !! el_body && el_body.scrollTop > 0);
|
||||
};
|
||||
$dialog.get(0).addEventListener('scroll', syncScrolled, { capture: true, passive: true });
|
||||
|
||||
const refresh = () => {
|
||||
const screen = top();
|
||||
syncScrolled();
|
||||
$back.prop('hidden', ! (stack.length > 1 || screen?.onBack));
|
||||
$back.prop('disabled', busy);
|
||||
const el_title = screen?.$el.find('.auth-title').get(0);
|
||||
$dialog.attr('aria-labelledby', el_title?.id || null);
|
||||
};
|
||||
|
||||
/** Takes the top screen off the stack and shows the one under it. */
|
||||
const pop = () => {
|
||||
const screen = stack.pop();
|
||||
screen.$el.remove();
|
||||
screen.onLeave?.();
|
||||
const under = top();
|
||||
under.$el.prop('hidden', false);
|
||||
return under;
|
||||
};
|
||||
|
||||
/** @type {AuthFlow} */
|
||||
const flow = {
|
||||
options: authFlowOptions(options),
|
||||
$overlay,
|
||||
$dialog,
|
||||
page: as_page,
|
||||
dismissable,
|
||||
result,
|
||||
push (screen) {
|
||||
const under = top();
|
||||
if ( under ) {
|
||||
under.el_return = $dialog.get(0).contains(document.activeElement) ? document.activeElement : null;
|
||||
under.$el.prop('hidden', true);
|
||||
}
|
||||
const $el = $(`<section class="auth-screen" data-screen="${html_encode(screen.name)}">${screen.html}</section>`);
|
||||
$el.find('.auth-title').first().attr('id', `${id}-title-${stack.length}`);
|
||||
$dialog.append($el);
|
||||
const record = { ...screen, $el };
|
||||
stack.push(record);
|
||||
refresh();
|
||||
focusScreen(record);
|
||||
return $el;
|
||||
},
|
||||
back () {
|
||||
if ( busy || isClosed() ) return;
|
||||
if ( stack.length > 1 ) {
|
||||
const under = pop();
|
||||
refresh();
|
||||
const el_return = under.el_return;
|
||||
if ( el_return && under.$el.get(0).contains(el_return) && ! el_return.disabled ) {
|
||||
el_return.focus({ preventScroll: true });
|
||||
} else {
|
||||
focusScreen(under);
|
||||
}
|
||||
return;
|
||||
}
|
||||
top()?.onBack?.();
|
||||
},
|
||||
goTo (name) {
|
||||
const index = stack.findIndex((screen) => screen.name === name);
|
||||
if ( index === -1 ) return false;
|
||||
while ( stack.length > index + 1 ) pop();
|
||||
refresh();
|
||||
focusScreen(top());
|
||||
return true;
|
||||
},
|
||||
setBusy (value) {
|
||||
const was_busy = busy;
|
||||
busy = !! value;
|
||||
// Busy screens lock their fields, and a disabled field drops focus
|
||||
// to the page, where the desktop's shortcuts would take the keys.
|
||||
const el_dialog = $dialog.get(0);
|
||||
const el_active = document.activeElement;
|
||||
if ( busy && ! was_busy ) {
|
||||
el_busy_return = el_active !== el_dialog && el_dialog.contains(el_active) ? el_active : null;
|
||||
}
|
||||
if ( busy && el_dialog.contains(el_active) ) el_dialog.focus({ preventScroll: true });
|
||||
$dialog.toggleClass('auth-dialog-busy', busy).attr('aria-busy', busy ? 'true' : null);
|
||||
refresh();
|
||||
if ( busy || ! was_busy ) return;
|
||||
// A failed request hands the keys back to where the user was, so
|
||||
// Enter can retry. A screen that already moved focus keeps it.
|
||||
const el_return = el_busy_return;
|
||||
el_busy_return = null;
|
||||
if ( el_return && document.activeElement === el_dialog && el_dialog.contains(el_return)
|
||||
&& ! el_return.disabled && ! el_return.closest('[hidden]') ) {
|
||||
el_return.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
isBusy: () => busy,
|
||||
close () {
|
||||
if ( isClosed() ) return;
|
||||
teardown();
|
||||
closeOverlay();
|
||||
},
|
||||
settle (value) {
|
||||
if ( settled ) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
},
|
||||
finish (value) {
|
||||
flow.close();
|
||||
flow.settle(value);
|
||||
},
|
||||
isClosed,
|
||||
focus: () => focusScreen(top()),
|
||||
};
|
||||
|
||||
$back.on('click', () => flow.back());
|
||||
|
||||
// Keys typed here are the card's. The desktop's shortcuts listen on the
|
||||
// document (Enter opens the selected item, Delete trashes it, letters
|
||||
// search the desktop), so they must not reach it. Escape is handled here
|
||||
// for the same reason.
|
||||
$overlay.on('keydown keyup keypress', function (e) {
|
||||
e.stopPropagation();
|
||||
if ( e.type !== 'keydown' || e.key !== 'Escape' ) return;
|
||||
e.preventDefault();
|
||||
if ( dismissable && ! busy ) $dialog.find('.dashboard-dialog-close').trigger('click');
|
||||
});
|
||||
// A press on the backdrop (or a page's background) would move focus to
|
||||
// the page, and with it the keys; it stays where it was.
|
||||
$overlay.on('mousedown', function (e) {
|
||||
if ( e.target === this ) e.preventDefault();
|
||||
});
|
||||
|
||||
active_flow = flow;
|
||||
return flow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the flow that is already open, if any: a second sign-in prompt
|
||||
* (an app asking while the user is mid-login) waits on the first rather than
|
||||
* stacking another card over it.
|
||||
*
|
||||
* @returns {Promise<unknown> | null}
|
||||
*/
|
||||
export const joinAuthFlow = () => {
|
||||
const open = activeAuthFlow();
|
||||
if ( ! open ) return null;
|
||||
// Bring focus back to the card, without taking it from a field the
|
||||
// user is typing in there.
|
||||
if ( ! open.$dialog.get(0).contains(document.activeElement) ) open.focus();
|
||||
return open.result;
|
||||
};
|
||||
|
||||
/**
|
||||
* The top of a screen: the Puter logo (beside the opener's, in a sign-in
|
||||
* popup), the screen's title, and, in a popup, which site sent the user.
|
||||
*
|
||||
* @param {AuthFlow} flow
|
||||
* @param {string} title Plain text.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const authHeadHtml = (flow, title) => {
|
||||
const opener = window.embedded_in_popup && isAttestedOrigin(window.openerOrigin) ? window.openerOrigin : '';
|
||||
let h = '<div class="auth-head">';
|
||||
h += authLogoHeader({
|
||||
logoSrc: window.icons['logo-white.svg'],
|
||||
logoHref: logoHref(flow),
|
||||
openerOrigin: opener,
|
||||
openerFallbackSrc: window.icons['website.svg'],
|
||||
});
|
||||
h += `<h1 class="auth-title">${html_encode(title)}</h1>`;
|
||||
if ( opener ) {
|
||||
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(opener).hostname])}</p>`;
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/**
|
||||
* On a cover page, the logo is a way out: desktop users stay on the desktop,
|
||||
* everyone else goes to the root dashboard.
|
||||
*
|
||||
* @param {AuthFlow} flow
|
||||
* @returns {string} Empty where the logo leads nowhere.
|
||||
*/
|
||||
const logoHref = (flow) => {
|
||||
if ( ! flow.page || window.embedded_in_popup ) return '';
|
||||
return window.location.pathname === '/desktop' ? '/desktop' : '/';
|
||||
};
|
||||
|
||||
/**
|
||||
* Wires a screen's head once it is on the page.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
*/
|
||||
export const wireAuthHead = ($screen) => {
|
||||
wireAuthLogoHeader($screen.get(0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets a screen's status line. Plain text unless `html` is given, which the
|
||||
* caller has already encoded.
|
||||
*
|
||||
* @param {jQuery} $screen
|
||||
* @param {string} text Empty clears it.
|
||||
* @param {{ kind?: 'error'|'info'|'success', html?: string, field?: string }} [opts]
|
||||
* `field` is a selector for the control to mark invalid and focus.
|
||||
*/
|
||||
export const setAuthStatus = ($screen, text, { kind = 'error', html, field } = {}) => {
|
||||
const $status = $screen.find('.dashboard-form-status').first();
|
||||
if ( html !== undefined ) $status.html(html);
|
||||
else $status.text(text ?? '');
|
||||
const shown = !! (text || html);
|
||||
$status
|
||||
.toggleClass('dashboard-form-status-error', shown && kind === 'error')
|
||||
.toggleClass('dashboard-form-status-info', shown && kind === 'info')
|
||||
.toggleClass('auth-status-success', shown && kind === 'success');
|
||||
$screen.find('[aria-invalid]').removeAttr('aria-invalid');
|
||||
if ( ! shown ) return;
|
||||
const el_status = $status.get(0);
|
||||
el_status?.scrollIntoView?.({ block: 'nearest' });
|
||||
if ( field ) {
|
||||
const el_field = $screen.find(field).attr('aria-invalid', 'true').get(0);
|
||||
if ( el_field && ! el_field.disabled ) el_field.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows a form's submit button as working, and locks the form's fields,
|
||||
* while `busy`.
|
||||
*
|
||||
* @param {jQuery} $form
|
||||
* @param {boolean} busy
|
||||
*/
|
||||
export const setFormBusy = ($form, busy) => {
|
||||
$form.find('input, select, textarea, button').prop('disabled', busy);
|
||||
$form.find('.auth-submit')
|
||||
.toggleClass('dashboard-form-busy', busy)
|
||||
.attr('aria-busy', busy ? 'true' : null);
|
||||
};
|
||||
|
||||
/**
|
||||
* The submit button of an auth form: full width, with a spinner while busy.
|
||||
*
|
||||
* @param {string} label Plain text.
|
||||
* @param {string} [extraClass]
|
||||
* @returns {string}
|
||||
*/
|
||||
export const submitButtonHtml = (label, extraClass = '') =>
|
||||
`<button type="submit" class="dashboard-dialog-btn dashboard-dialog-btn-primary auth-submit ${extraClass}">`
|
||||
+ '<span class="dashboard-form-spinner" aria-hidden="true"></span>'
|
||||
+ `<span class="dashboard-form-submit-label">${html_encode(label)}</span>`
|
||||
+ '</button>';
|
||||
@@ -0,0 +1,305 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetAuthGlobals, tick } from './authTestSetup.js';
|
||||
|
||||
const { default: openAuthFlow, activeAuthFlow, authFlowOptions, authHeadHtml, joinAuthFlow, navigateAfterSignIn } = await import('./authFlow.js');
|
||||
|
||||
const key = (target, k) => target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true }));
|
||||
const backdropClick = () => {
|
||||
const el = $('.auth-dialog-overlay').last().get(0);
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, detail: 1 }));
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthGlobals();
|
||||
activeAuthFlow()?.finish(false);
|
||||
});
|
||||
|
||||
describe('openAuthFlow', () => {
|
||||
it('mounts one card over everything, not a window', async () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<h1 class="auth-title">A</h1>' });
|
||||
await tick();
|
||||
expect($('.auth-dialog-overlay.dashboard-dialog-overlay')).toHaveLength(1);
|
||||
expect($('.window')).toHaveLength(0);
|
||||
expect($('.auth-dialog').attr('aria-labelledby')).toBe($('.auth-title').attr('id'));
|
||||
});
|
||||
|
||||
it('shows as a page, without ✕, for cover pages and every popup', () => {
|
||||
openAuthFlow({ page: true }).finish(false);
|
||||
window.embedded_in_popup = true;
|
||||
const flow = openAuthFlow();
|
||||
expect(flow.page).toBe(true);
|
||||
expect($('.auth-dialog-overlay').last().hasClass('auth-dialog-page')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets ✕ and Escape dismiss a dialog, settling false', async () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
key($('.auth-dialog input').get(0), 'Escape');
|
||||
await expect(flow.result).resolves.toBe(false);
|
||||
expect(activeAuthFlow()).toBeNull();
|
||||
|
||||
const second = openAuthFlow();
|
||||
second.push({ name: 'a', html: '' });
|
||||
$('.dashboard-dialog-close').last().trigger('click');
|
||||
await expect(second.result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a page up: no ✕, Escape and backdrop do nothing', async () => {
|
||||
const flow = openAuthFlow({ page: true });
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
await tick();
|
||||
expect($('.dashboard-dialog-close')).toHaveLength(0);
|
||||
key($('.auth-dialog input').get(0), 'Escape');
|
||||
backdropClick();
|
||||
expect(flow.isClosed()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the backdrop unless asked, so a stray click keeps typed credentials', async () => {
|
||||
const kept = openAuthFlow();
|
||||
kept.push({ name: 'a', html: '' });
|
||||
await tick();
|
||||
backdropClick();
|
||||
expect(kept.isClosed()).toBe(false);
|
||||
kept.finish(false);
|
||||
|
||||
const closing = openAuthFlow({ closeOnBackdrop: true });
|
||||
closing.push({ name: 'a', html: '' });
|
||||
await tick();
|
||||
backdropClick();
|
||||
await expect(closing.result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('holds Escape while busy; only ✕ dismisses then', async () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
flow.setBusy(true);
|
||||
key($('.auth-dialog input').get(0), 'Escape');
|
||||
expect(flow.isClosed()).toBe(false);
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
await expect(flow.result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('keeps typed keys from reaching the desktop shortcuts on the document', () => {
|
||||
const flow = openAuthFlow({ page: true });
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
const seen = vi.fn();
|
||||
document.addEventListener('keydown', seen);
|
||||
key($('.auth-dialog input').get(0), 'Delete');
|
||||
key($('.auth-dialog input').get(0), 'Enter');
|
||||
document.removeEventListener('keydown', seen);
|
||||
expect(seen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps focus in the card while busy, since a locked field would drop it to the page', () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
const el_input = $('.auth-dialog input').get(0);
|
||||
el_input.focus();
|
||||
flow.setBusy(true);
|
||||
el_input.disabled = true;
|
||||
expect(document.activeElement).toBe($('.auth-dialog').get(0));
|
||||
});
|
||||
|
||||
it('hands focus back to where it was once busy ends, so Enter can retry', () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input class="one"><input class="two">' });
|
||||
const el_two = $('.auth-dialog .two').get(0);
|
||||
el_two.focus();
|
||||
flow.setBusy(true);
|
||||
el_two.disabled = true;
|
||||
el_two.disabled = false;
|
||||
flow.setBusy(false);
|
||||
expect(document.activeElement).toBe(el_two);
|
||||
|
||||
// A screen that moved focus on its own keeps it there.
|
||||
flow.setBusy(true);
|
||||
$('.auth-dialog .one').get(0).focus();
|
||||
flow.setBusy(false);
|
||||
expect(document.activeElement).toBe($('.auth-dialog .one').get(0));
|
||||
});
|
||||
|
||||
it('keeps focus in the card when the backdrop is pressed', async () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input>' });
|
||||
const press = new MouseEvent('mousedown', { bubbles: true, cancelable: true, detail: 1 });
|
||||
$('.auth-dialog-overlay').get(0).dispatchEvent(press);
|
||||
expect(press.defaultPrevented).toBe(true);
|
||||
$('.auth-dialog-overlay').get(0).dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
|
||||
expect(flow.isClosed()).toBe(false);
|
||||
|
||||
const inside = new MouseEvent('mousedown', { bubbles: true, cancelable: true, detail: 1 });
|
||||
$('.auth-dialog input').get(0).dispatchEvent(inside);
|
||||
expect(inside.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('stacks screens, keeping what was typed, with Back and goTo', () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'login', html: '<input class="typed">' });
|
||||
$('.typed').val('kept');
|
||||
expect($('.auth-dialog-back').prop('hidden')).toBe(true);
|
||||
|
||||
const left = vi.fn();
|
||||
flow.push({ name: 'signup', html: '<p>signup</p>', onLeave: left });
|
||||
expect($('[data-screen="login"]').prop('hidden')).toBe(true);
|
||||
expect($('.auth-dialog-back').prop('hidden')).toBe(false);
|
||||
|
||||
flow.back();
|
||||
expect(left).toHaveBeenCalledOnce();
|
||||
expect($('[data-screen="signup"]')).toHaveLength(0);
|
||||
expect($('[data-screen="login"]').prop('hidden')).toBe(false);
|
||||
expect($('.typed').val()).toBe('kept');
|
||||
|
||||
flow.push({ name: 'signup', html: '' });
|
||||
flow.push({ name: 'recover', html: '' });
|
||||
expect(flow.goTo('login')).toBe(true);
|
||||
expect($('.auth-screen')).toHaveLength(1);
|
||||
expect(flow.goTo('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the card scrolled while the showing screen is, so the bar covers what passes under it', () => {
|
||||
const flow = openAuthFlow();
|
||||
const scroll = (screen, top) => {
|
||||
const el = $(`[data-screen="${screen}"] .auth-body`).get(0);
|
||||
el.scrollTop = top;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
};
|
||||
flow.push({ name: 'a', html: '<div class="auth-body"></div>' });
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(false);
|
||||
scroll('a', 40);
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(true);
|
||||
flow.push({ name: 'b', html: '<div class="auth-body"></div>' });
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(false);
|
||||
flow.back();
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(true);
|
||||
scroll('a', 0);
|
||||
expect($('.auth-dialog').hasClass('auth-dialog-scrolled')).toBe(false);
|
||||
});
|
||||
|
||||
it('offers Back on a first screen that asks for it', () => {
|
||||
const flow = openAuthFlow({ page: true });
|
||||
const back = vi.fn();
|
||||
flow.push({ name: 'only', html: '', onBack: back });
|
||||
expect($('.auth-dialog-back').prop('hidden')).toBe(false);
|
||||
$('.auth-dialog-back').trigger('click');
|
||||
expect(back).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('tells waiting screens when the flow closes', () => {
|
||||
const flow = openAuthFlow();
|
||||
const left = vi.fn();
|
||||
flow.push({ name: 'a', html: '', onLeave: left });
|
||||
flow.finish(true);
|
||||
expect(left).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('lets a second prompt join the open flow instead of stacking another card', async () => {
|
||||
expect(joinAuthFlow()).toBeNull();
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '' });
|
||||
const joined = joinAuthFlow();
|
||||
expect($('.auth-dialog')).toHaveLength(1);
|
||||
flow.finish(true);
|
||||
await expect(joined).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the field being typed in when a second prompt joins', () => {
|
||||
const flow = openAuthFlow();
|
||||
flow.push({ name: 'a', html: '<input class="first"><input class="second">' });
|
||||
$('.second').get(0).focus();
|
||||
joinAuthFlow();
|
||||
expect(document.activeElement).toBe($('.second').get(0));
|
||||
|
||||
// Focus that wandered off the card is brought back to it.
|
||||
$('.second').get(0).blur();
|
||||
joinAuthFlow();
|
||||
expect($('.auth-dialog').get(0).contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authHeadHtml', () => {
|
||||
it('makes the logo a keyboard-reachable way out only on a cover page', () => {
|
||||
const page = openAuthFlow({ page: true });
|
||||
const $page_head = $(authHeadHtml(page, 'Log in'));
|
||||
expect($page_head.find('a.auth-logo-link').attr('href')).toBe('/');
|
||||
expect($page_head.find('.auth-logo').attr('alt')).toBe('Puter');
|
||||
page.finish(false);
|
||||
|
||||
const dialog = openAuthFlow();
|
||||
const $dialog_head = $(authHeadHtml(dialog, 'Log in'));
|
||||
expect($dialog_head.find('a')).toHaveLength(0);
|
||||
expect($dialog_head.find('.auth-logo').attr('alt')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authFlowOptions', () => {
|
||||
it('defaults to reloading on success, to the page the flow started on', () => {
|
||||
const options = authFlowOptions();
|
||||
expect(options.reload_on_success).toBe(true);
|
||||
expect(options.redirect_url).toBe(window.location.href);
|
||||
expect(options.show_signup_button).toBe(true);
|
||||
expect(options.send_confirmation_code).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an explicit empty redirect', () => {
|
||||
expect(authFlowOptions({ redirect_url: '' }).redirect_url).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigateAfterSignIn', () => {
|
||||
const withLocation = (href, run) => {
|
||||
const original = window.location;
|
||||
const replace = vi.fn();
|
||||
const reload = vi.fn();
|
||||
delete window.location;
|
||||
window.location = { href, replace, reload };
|
||||
const spy = vi.spyOn(history, 'replaceState').mockImplementation(() => {});
|
||||
let states;
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
window.location = original;
|
||||
states = spy.mock.calls.map((call) => call[2]);
|
||||
spy.mockRestore();
|
||||
}
|
||||
return { replace, reload, states };
|
||||
};
|
||||
|
||||
it('goes to another page by replacing this one', () => {
|
||||
const { replace, reload } = withLocation('https://puter.test/action/login', () => {
|
||||
navigateAfterSignIn('/');
|
||||
});
|
||||
expect(replace).toHaveBeenCalledWith('/');
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Replacing with a hash-only change would scroll, never reload.
|
||||
it('reloads when only the hash would change, as on a dashboard tab', () => {
|
||||
const { replace, reload, states } = withLocation('https://puter.test/#files', () => {
|
||||
navigateAfterSignIn('https://puter.test/#files');
|
||||
});
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
expect(states).toEqual(['https://puter.test/#files']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The "Sign in with Google" (and other identity provider) buttons of the
|
||||
// login and signup screens. A button navigates to the backend's OIDC start
|
||||
// route, which comes back to the page the flow started from.
|
||||
|
||||
import { get_oidc_return_to } from '../../helpers/authRedirect.js';
|
||||
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../../util/openid.js';
|
||||
import { offersFederatedSignInInPopup } from '../../util/popupAuth.js';
|
||||
|
||||
const GOOGLE_ICON = '<img alt="" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA">';
|
||||
const MICROSOFT_ICON = '<svg aria-hidden="true" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>';
|
||||
const APPLE_ICON = '<svg aria-hidden="true" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>';
|
||||
|
||||
const KNOWN = {
|
||||
google: { icon: GOOGLE_ICON, login: 'sign_in_with_google', signup: 'sign_up_with_google' },
|
||||
microsoft: { icon: MICROSOFT_ICON, login: 'sign_in_with_microsoft', signup: 'sign_up_with_microsoft' },
|
||||
apple: { icon: APPLE_ICON, login: 'sign_in_with_apple', signup: 'sign_up_with_apple' },
|
||||
};
|
||||
|
||||
// Shown in this order whatever order the server lists them in.
|
||||
const KNOWN_ORDER = ['google', 'microsoft', 'apple'];
|
||||
|
||||
/**
|
||||
* Whether this page may offer a federated sign-in at all. A federated hop
|
||||
* navigates a popup away and the provider returns it as a plain sign-in
|
||||
* popup, losing whatever the popup was opened to do (and, for a permission
|
||||
* prompt, handing the opener a token instead of a decision). A
|
||||
* cross-origin-isolated opener can only get the token through the session
|
||||
* relay, which the provider's return doesn't keep.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const mayOfferProviders = () => ! (window.embedded_in_popup
|
||||
&& ( ! offersFederatedSignInInPopup(window.gui_action)
|
||||
|| window.url_query_params?.get('cross_origin_isolated') === 'true'));
|
||||
|
||||
/**
|
||||
* The URL that starts a provider's sign-in.
|
||||
*
|
||||
* @param {string} provider
|
||||
* @param {'login'|'signup'} kind
|
||||
* @param {string | undefined} referrer
|
||||
* @returns {string}
|
||||
*/
|
||||
export const providerStartUrl = (provider, kind, referrer) => {
|
||||
let url = `${window.gui_origin}/auth/oidc/${encodeURIComponent(provider)}/start?flow=${kind}`;
|
||||
// Return to the interface the flow started from (the backend whitelists the path).
|
||||
const return_to = get_oidc_return_to();
|
||||
if ( return_to ) url += `&return_to=${encodeURIComponent(return_to)}`;
|
||||
if ( referrer ) url += `&referrer=${encodeURIComponent(referrer)}`;
|
||||
// A provider sign-in can create the account, so a signup link's code rides along.
|
||||
if ( window.signup_bonus_code ) url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
|
||||
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if ( window.openerOrigin ) url += `&opener_origin=${encodeURIComponent(window.openerOrigin)}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the configured providers and fills `$list` with a button for each.
|
||||
* A self-hosted instance's own provider gets a generic button.
|
||||
*
|
||||
* @param {jQuery} $list Where the buttons go.
|
||||
* @param {object} opts
|
||||
* @param {'login'|'signup'} opts.kind
|
||||
* @param {() => string | undefined} opts.referrer Read when a button is pressed.
|
||||
* @returns {Promise<{ providers: string[], signupOidcOnly: boolean } | null>}
|
||||
* Null when none are offered here or the lookup failed.
|
||||
*/
|
||||
export async function renderProviderButtons ($list, { kind, referrer }) {
|
||||
if ( ! mayOfferProviders() ) return null;
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
if ( ! res.ok ) return null;
|
||||
data = await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if ( ! Array.isArray(data?.providers) || ! data.providers.length ) return null;
|
||||
|
||||
const ordered = [
|
||||
...KNOWN_ORDER.filter((provider) => data.providers.includes(provider)),
|
||||
...data.providers.filter((provider) => ! KNOWN_OIDC_PROVIDERS.includes(provider)),
|
||||
];
|
||||
let h = '';
|
||||
for ( const provider of ordered ) {
|
||||
const known = KNOWN[provider];
|
||||
const label = known
|
||||
? i18n(known[kind])
|
||||
: i18n(kind === 'login' ? 'sign_in_with_provider' : 'sign_up_with_provider', [humanizeOidcProviderId(provider)]);
|
||||
const icon = known ? known.icon : OIDC_GENERIC_PROVIDER_ICON;
|
||||
h += `<button type="button" class="dashboard-dialog-btn auth-provider-btn oidc-${known ? provider : 'custom'}-btn" data-provider="${html_encode(provider)}">`;
|
||||
h += `<span class="auth-provider-icon" aria-hidden="true">${icon}</span><span>${label}</span>`;
|
||||
h += '</button>';
|
||||
}
|
||||
$list.html(h);
|
||||
$list.on('click', '.auth-provider-btn', function () {
|
||||
window.location.href = providerStartUrl($(this).attr('data-provider'), kind, referrer());
|
||||
});
|
||||
return { providers: ordered, signupOidcOnly: data.signupOidcOnly === true };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// Shared jsdom setup for the auth dialog tests: the globals the GUI
|
||||
// installs at boot, reset between tests.
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.def = globalThis.def ?? ((value) => value);
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, args) => (Array.isArray(args) && args.length ? `${key}:${args.join(',')}` : key);
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
window.matchMedia = () => ({ matches: false });
|
||||
|
||||
export const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A JSON fetch response. */
|
||||
export const reply = (status, data) => new Response(JSON.stringify(data), { status });
|
||||
|
||||
/** Resets the boot globals the screens read. */
|
||||
export const resetAuthGlobals = () => {
|
||||
document.body.innerHTML = '';
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.app_domain = 'puter.com';
|
||||
window.gui_params = {};
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => `id-${Math.random().toString(36).slice(2)}`;
|
||||
window.is_email = (value) => String(value).includes('@');
|
||||
window.embedded_in_popup = false;
|
||||
window.gui_action = undefined;
|
||||
window.openerOrigin = undefined;
|
||||
window.referrerStr = undefined;
|
||||
window.signup_bonus_code = null;
|
||||
window.url_query_params = new URLSearchParams('');
|
||||
window.custom_headers = undefined;
|
||||
window.turnstile = undefined;
|
||||
window.logged_in_users = [];
|
||||
window.auth_token = undefined;
|
||||
window.update_auth_data = vi.fn(async () => {});
|
||||
globalThis.fetch = vi.fn(async () => reply(200, { providers: [] }));
|
||||
};
|
||||
|
||||
/** The screen showing in the auth card. */
|
||||
export const screen = () => $('.auth-dialog .auth-screen:not([hidden])');
|
||||
@@ -23,7 +23,7 @@ import UIWindow from '../UIWindow.js';
|
||||
import UIContextMenu from '../UIContextMenu.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
|
||||
import UIWindowLogin from '../UIWindowLogin.js';
|
||||
import UIAuthLogin from '../Auth/UIAuthLogin.js';
|
||||
import UIWindowFeedback from '../UIWindowFeedback.js';
|
||||
import apply_item_added_to_containers from '../../helpers/applyItemAddedToContainers.js';
|
||||
import { clear_shared_param } from '../../helpers/parseSharedPath.js';
|
||||
@@ -514,9 +514,9 @@ async function UIDashboard (options) {
|
||||
|
||||
if ( sharedLinkNeedsLogin ) {
|
||||
// Not awaited: the dashboard is already up underneath the cover page.
|
||||
UIWindowLogin({
|
||||
UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
window_options: { cover_page: true, has_head: false },
|
||||
page: true,
|
||||
}).then(() => {
|
||||
// Dismissed without signing in: drop the link rather than loop.
|
||||
if ( window.user?.is_temp ) clear_shared_param();
|
||||
@@ -875,21 +875,10 @@ async function UIDashboard (options) {
|
||||
items.push({
|
||||
html: i18n('add_another_account'),
|
||||
onClick: async function () {
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
send_confirmation_code: false,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
// Phones/tablets pin every .window to one z-index
|
||||
// (style.css), which puts the fullpage dashboard
|
||||
// above this dialog's backdrop wrapper; stay_on_top
|
||||
// lifts the dialog into the 99999999+ band like
|
||||
// every other backdropped dashboard dialog.
|
||||
stay_on_top: true,
|
||||
},
|
||||
closeOnBackdrop: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,7 +76,14 @@ const attrs_html = (attrs) => Object.entries(attrs ?? {})
|
||||
.map(([key, value]) => (value === true ? ` ${key}` : ` ${key}="${html_encode(String(value))}"`))
|
||||
.join('');
|
||||
|
||||
const field_html = (field, id) => {
|
||||
/**
|
||||
* A labelled form field's markup, as the form dialog draws it.
|
||||
*
|
||||
* @param {FormDialogField} field
|
||||
* @param {string} id Prefix for the control's id, unique to the dialog.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formFieldHtml = (field, id) => {
|
||||
const control_id = `${id}-${field.name}`;
|
||||
const hint_id = `${control_id}-hint`;
|
||||
const suffix_id = `${control_id}-suffix`;
|
||||
@@ -127,6 +134,25 @@ const field_html = (field, id) => {
|
||||
+ '</div>';
|
||||
};
|
||||
|
||||
/**
|
||||
* Wires the show/hide toggles of the password fields under `$root`.
|
||||
*
|
||||
* @param {jQuery} $root
|
||||
*/
|
||||
export const bindPasswordReveal = ($root) => {
|
||||
// Keep the field focused (and a phone's keyboard up) while toggling. The
|
||||
// label names the action, so the button carries no aria-pressed as well.
|
||||
$root.on('pointerdown mousedown', '.dashboard-form-reveal', (e) => e.preventDefault());
|
||||
$root.on('click', '.dashboard-form-reveal', function () {
|
||||
const $input = $(this).siblings('input');
|
||||
const reveal = $input.attr('type') === 'password';
|
||||
const label = i18n(reveal ? 'hide_password' : 'show_password', [], false);
|
||||
$input.attr('type', reveal ? 'text' : 'password');
|
||||
$(this).attr({ 'aria-label': label, title: label })
|
||||
.html(reveal ? FORM_ICONS.hide : FORM_ICONS.show);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* A read-only value with a Copy button, for a `finish` screen. The dialog
|
||||
* wires the button.
|
||||
@@ -179,7 +205,7 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
if ( danger ) h += `<div class="dashboard-dialog-icon">${ICONS.danger}</div>`;
|
||||
h += '<div class="dashboard-dialog-content">';
|
||||
if ( intro ) h += `<div class="dashboard-dialog-message" id="${id}-intro">${intro}</div>`;
|
||||
h += fields.map((field) => field_html(field, id)).join('');
|
||||
h += fields.map((field) => formFieldHtml(field, id)).join('');
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += '</div></div>';
|
||||
h += '<div class="dashboard-dialog-footer">';
|
||||
@@ -317,17 +343,7 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
});
|
||||
|
||||
// Keep the field focused (and a phone's keyboard up) while toggling. The
|
||||
// label names the action, so the button carries no aria-pressed as well.
|
||||
$dialog.on('pointerdown mousedown', '.dashboard-form-reveal', (e) => e.preventDefault());
|
||||
$dialog.on('click', '.dashboard-form-reveal', function () {
|
||||
const $input = $(this).siblings('input');
|
||||
const reveal = $input.attr('type') === 'password';
|
||||
const label = i18n(reveal ? 'hide_password' : 'show_password', [], false);
|
||||
$input.attr('type', reveal ? 'text' : 'password');
|
||||
$(this).attr({ 'aria-label': label, title: label })
|
||||
.html(reveal ? FORM_ICONS.hide : FORM_ICONS.show);
|
||||
});
|
||||
bindPasswordReveal($dialog);
|
||||
|
||||
$dialog.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -27,7 +27,7 @@ import UIWindowSaveAccount from './UIWindowSaveAccount.js';
|
||||
import UIWindowDesktopBGSettings from './UIWindowDesktopBGSettings.js';
|
||||
import UIWindowMyWebsites from './UIWindowMyWebsites.js';
|
||||
import UIWindowFeedback from './UIWindowFeedback.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import UIWindowQR from './UIWindowQR.js';
|
||||
|
||||
import UIWindowProgress from './UIWindowProgress.js';
|
||||
@@ -1901,9 +1901,9 @@ async function UIDesktop (options) {
|
||||
await open_path_target(stat.path ?? shared_path, stat);
|
||||
return;
|
||||
}
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
window_options: { cover_page: true, has_head: false },
|
||||
page: true,
|
||||
});
|
||||
// Dismissed without signing in: drop the link rather than loop.
|
||||
if ( window.user?.is_temp ) clear_shared_param();
|
||||
@@ -2235,12 +2235,9 @@ $(document).on('click', '.user-options-menu-btn', async function (e) {
|
||||
html: i18n('add_another_account'),
|
||||
// icon: l_user.username === user.username ? '✓' : '',
|
||||
onClick: async function (val) {
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
send_confirmation_code: false,
|
||||
window_options: {
|
||||
has_head: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -2423,16 +2420,12 @@ $(document).on('click', '.user-options-login-btn', async function (e) {
|
||||
});
|
||||
if ( saved )
|
||||
{
|
||||
UIWindowLogin({ show_signup_button: false, reload_on_success: true });
|
||||
UIAuthLogin({ show_signup_button: false, reload_on_success: true });
|
||||
}
|
||||
} else if ( alert_resp === 'login' ) {
|
||||
UIWindowLogin({
|
||||
UIAuthLogin({
|
||||
show_signup_button: false,
|
||||
reload_on_success: true,
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import UIAlert from './UIAlert.js';
|
||||
import UIContextMenu from './UIContextMenu.js';
|
||||
import path from '../lib/path.js';
|
||||
import UITaskbarItem from './UITaskbarItem.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
@@ -1288,7 +1288,7 @@ async function UIWindow (options) {
|
||||
// directory content
|
||||
// --------------------------------------------------------
|
||||
//auth
|
||||
if ( !window.is_auth() && !(await UIWindowLogin()) )
|
||||
if ( !window.is_auth() && !(await UIAuthLogin()) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,460 +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/>.
|
||||
*/
|
||||
|
||||
import UIWindowLogin2FA from './UIWindowLogin2FA.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIWindowRecoverPassword from './UIWindowRecoverPassword.js';
|
||||
import UIWindowSignup from './UIWindowSignup.js';
|
||||
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../util/openid.js';
|
||||
import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
|
||||
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedirect.js';
|
||||
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
|
||||
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
|
||||
|
||||
async function UIWindowLogin (options) {
|
||||
options = options ?? {};
|
||||
|
||||
if ( options.reload_on_success === undefined )
|
||||
{
|
||||
options.reload_on_success = true;
|
||||
}
|
||||
|
||||
if ( options.redirect_url === undefined )
|
||||
{
|
||||
// stay on the page the login started from (e.g. an /app/<name>
|
||||
// landing), with standalone auth pages going to the root dashboard
|
||||
options.redirect_url = get_auth_redirect_url();
|
||||
}
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const internal_id = window.uuidv4();
|
||||
|
||||
let h = '';
|
||||
h += '<div style="max-width:100%; width:100%; height:100%; min-height:0; box-sizing:border-box; display:flex; flex-direction:column; justify-content:center; align-items:stretch; padding:0; overflow:auto; color:var(--color-text);">';
|
||||
// logo
|
||||
const logo_clickable = !!options.window_options?.cover_page && !window.embedded_in_popup;
|
||||
h += '<div class="logo-wrapper" style="display:flex; justify-content:center; padding:20px 20px 0 20px; margin-bottom: 0;">';
|
||||
h += authLogoHeader({
|
||||
logoSrc: window.icons['logo-white.svg'],
|
||||
logoClickable: logo_clickable,
|
||||
openerOrigin: window.embedded_in_popup && isAttestedOrigin(window.openerOrigin) ? window.openerOrigin : '',
|
||||
openerFallbackSrc: window.icons['website.svg'],
|
||||
});
|
||||
h += '</div>';
|
||||
// title
|
||||
h += '<div style="padding:10px 20px; text-align:center; margin-bottom:0;">';
|
||||
h += `<h1 class="login-form-title">${i18n('log_in')}</h1>`;
|
||||
// In a sign-in popup, say which site brought the user here.
|
||||
if (window.embedded_in_popup && isAttestedOrigin(window.openerOrigin)) {
|
||||
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(window.openerOrigin).hostname])}</p>`;
|
||||
}
|
||||
h += '</div>';
|
||||
// form
|
||||
h += '<div style="padding:20px; overflow-y:auto; overflow-x:hidden;">';
|
||||
h += '<form class="login-form" style="width:100%; width: 100%; max-width: 400px; margin: 0 auto;">';
|
||||
// server messages
|
||||
h += '<div class="login-error-msg" style="color:#e74c3c; display:none; margin-bottom:10px; line-height:15px; font-size:13px;"></div>';
|
||||
// email or username
|
||||
h += '<div style="position: relative; margin-bottom: 20px;">';
|
||||
h += `<label style="display:block; margin-bottom:5px;">${i18n('email_or_username')}</label>`;
|
||||
if ( options.email_or_username ) {
|
||||
h += `<input type="text" class="email_or_username" value="${options.email_or_username}" autocomplete="username"/>`;
|
||||
} else {
|
||||
h += '<input type="text" class="email_or_username" autocomplete="username"/>';
|
||||
}
|
||||
h += '</div>';
|
||||
// password
|
||||
h += '<div style="position: relative; margin-bottom: 20px;">';
|
||||
h += '<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px;">';
|
||||
h += `<label style="margin-bottom:0;">${i18n('password')}</label>`;
|
||||
h += `<span class="forgot-password-link" style="font-size:13px;">${i18n('forgot_pass_c2a')}</span>`;
|
||||
h += '</div>';
|
||||
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? 'text' : 'password'}" name="password" autocomplete="current-password"/>`;
|
||||
// show/hide icon
|
||||
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
|
||||
<img class="toggle-show-password-icon" src="${options.show_password ? window.icons['eye-closed.svg'] : window.icons['eye-open.svg']}" width="20" height="20">
|
||||
</span>`;
|
||||
h += '</div>';
|
||||
// login
|
||||
h += `<button type="submit" class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
|
||||
// OIDC
|
||||
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 0 0 10px 0;">';
|
||||
h += `<div style="display:flex; align-items:center; gap:10px; margin: 10px 0; font-size:13px; color:#888;"><div style="flex:1; height:1px; background-color:#d0d0d0;"></div><span>${ i18n('or') }</span><div style="flex:1; height:1px; background-color:#d0d0d0;"></div></div>`;
|
||||
h += `<button type="button" class="oidc-google-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_in_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-microsoft-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_in_with_microsoft')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_in_with_apple')}</button>`;
|
||||
h += '<div class="oidc-custom-providers"></div>';
|
||||
h += '</div>';
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
// create account link
|
||||
|
||||
// If show_signup_button is undefined, the default behavior is to show it.
|
||||
// If show_signup_button is set to false, the button will not be shown.
|
||||
if ( options.show_signup_button === undefined || options.show_signup_button ) {
|
||||
h += '<div class="c2a-wrapper" style="padding:20px;">';
|
||||
h += `<button class="signup-c2a-clickable">${i18n('create_free_account')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: null,
|
||||
app: 'login',
|
||||
single_instance: true,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: true,
|
||||
selectable_body: false,
|
||||
draggable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_draggable: options.is_draggable ?? true,
|
||||
is_droppable: false,
|
||||
is_resizable: false,
|
||||
stay_on_top: false,
|
||||
allow_native_ctxmenu: true,
|
||||
allow_user_select: true,
|
||||
...options.window_options,
|
||||
width: 350,
|
||||
dominant: true,
|
||||
on_close: () => {
|
||||
resolve(false);
|
||||
},
|
||||
onAppend: function (this_window) {
|
||||
if ( options.authError ) {
|
||||
$(this_window).find('.login-error-msg').html(html_encode(options.authError)).fadeIn();
|
||||
}
|
||||
if ( ! window.disable_login_autofocus )
|
||||
{
|
||||
$(this_window).find('.email_or_username').get(0).focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
window_class: 'window-login',
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
},
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
padding: '0',
|
||||
'background-color': 'rgb(255 255 255)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'justify-content': 'center',
|
||||
'align-items': 'center',
|
||||
},
|
||||
});
|
||||
|
||||
wireAuthLogoHeader(el_window);
|
||||
|
||||
if ( logo_clickable ) {
|
||||
$(el_window).find('.auth-logo').on('click', function () {
|
||||
// keep desktop users on the desktop; everyone else goes to the root dashboard
|
||||
window.location.href = window.location.pathname === '/desktop' ? '/desktop' : '/';
|
||||
});
|
||||
}
|
||||
|
||||
$(el_window).find('.forgot-password-link').on('click', function (e) {
|
||||
UIWindowRecoverPassword({
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
// A stay-on-top login window (dashboard mode) sits in the
|
||||
// 99999999+ band; this dialog must join it there or it
|
||||
// opens buried under the login window's backdrop.
|
||||
stay_on_top: isMobile.phone || !!options.window_options?.stay_on_top,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// A federated hop navigates this popup away and the provider
|
||||
// returns it as a plain sign-in popup, losing whatever the popup
|
||||
// was opened to do — and, for a permission prompt, handing the
|
||||
// opener a token instead of a decision. Don't offer it there.
|
||||
// A cross-origin-isolated opener can only get the token through
|
||||
// the session relay, which the provider's return doesn't keep.
|
||||
if ( window.embedded_in_popup
|
||||
&& ( ! offersFederatedSignInInPopup(window.gui_action)
|
||||
|| window.url_query_params?.get('cross_origin_isolated') === 'true' ) ) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
if ( ! res.ok ) return;
|
||||
const data = await res.json();
|
||||
if ( ! data.providers ) return;
|
||||
|
||||
const wireOidcBtn = (provider, btnClass) => {
|
||||
$(el_window).find(btnClass).css('display', 'flex');
|
||||
$(el_window).find(btnClass).on('click', function () {
|
||||
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=login`;
|
||||
|
||||
// return to the interface the login started from (backend whitelists the path)
|
||||
const return_to = get_oidc_return_to();
|
||||
if ( return_to ) {
|
||||
url += `&return_to=${encodeURIComponent(return_to)}`;
|
||||
}
|
||||
|
||||
const referrer = options.referrer ?? window.referrerStr ?? window.openerOrigin;
|
||||
if ( referrer ) {
|
||||
url += `&referrer=${encodeURIComponent(referrer)}`;
|
||||
}
|
||||
// A provider login can create the account, so a signup link's code rides along.
|
||||
if ( window.signup_bonus_code ) {
|
||||
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
|
||||
}
|
||||
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if ( window.openerOrigin ) {
|
||||
url += `&opener_origin=${encodeURIComponent(window.openerOrigin)}`;
|
||||
}
|
||||
}
|
||||
window.location.href = url;
|
||||
});
|
||||
};
|
||||
|
||||
let hasProvider = false;
|
||||
if ( data.providers.includes('google') ) { hasProvider = true; wireOidcBtn('google', '.oidc-google-btn'); }
|
||||
if ( data.providers.includes('apple') ) { hasProvider = true; wireOidcBtn('apple', '.oidc-apple-btn'); }
|
||||
if ( data.providers.includes('microsoft') ) { hasProvider = true; wireOidcBtn('microsoft', '.oidc-microsoft-btn'); }
|
||||
|
||||
// Any other configured provider (e.g. a self-hosted admin's
|
||||
// custom OIDC IdP) gets a generic button.
|
||||
data.providers
|
||||
.filter(provider => !KNOWN_OIDC_PROVIDERS.includes(provider))
|
||||
.forEach((provider, idx) => {
|
||||
hasProvider = true;
|
||||
const btnClass = `oidc-custom-btn-${idx}`;
|
||||
const label = humanizeOidcProviderId(provider);
|
||||
$(el_window).find('.oidc-custom-providers').append(
|
||||
`<button type="button" class="${btnClass} button button-block button-normal" style="display:flex; align-items:center; justify-content:center; gap:8px; margin-top:8px;">${OIDC_GENERIC_PROVIDER_ICON}${i18n('sign_in_with_provider', [label])}</button>`,
|
||||
);
|
||||
wireOidcBtn(provider, `.${btnClass}`);
|
||||
});
|
||||
|
||||
if ( hasProvider ) $(el_window).find('.oidc-providers-wrapper').show();
|
||||
} catch (_) {
|
||||
}
|
||||
})();
|
||||
|
||||
$(el_window).find('.login-btn').on('click', function (e) {
|
||||
// Prevent default button behavior (important for async requests)
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous error states
|
||||
$(el_window).find('.login-error-msg').hide();
|
||||
|
||||
const email_username = $(el_window).find('.email_or_username').val();
|
||||
const password = $(el_window).find('.password').val();
|
||||
|
||||
// Basic validation for email/username and password
|
||||
if ( ! email_username ) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('login_email_username_required'));
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! password ) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('login_password_required'));
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data for the request
|
||||
// When this login was triggered by a 401 reauth_required,
|
||||
// forward `auth_id` (and the originating reason) so the
|
||||
// backend can re-attach the user/session to the same
|
||||
// stable identity.
|
||||
const reauth_payload = options.auth_id
|
||||
? { auth_id: options.auth_id, reauth_reason: options.reauth_reason }
|
||||
: {};
|
||||
let data;
|
||||
if ( window.is_email(email_username) ) {
|
||||
data = JSON.stringify({
|
||||
email: email_username,
|
||||
password: password,
|
||||
...reauth_payload,
|
||||
});
|
||||
} else {
|
||||
data = JSON.stringify({
|
||||
username: email_username,
|
||||
password: password,
|
||||
...reauth_payload,
|
||||
});
|
||||
}
|
||||
|
||||
let headers = {};
|
||||
if ( window.custom_headers )
|
||||
{
|
||||
headers = window.custom_headers;
|
||||
}
|
||||
|
||||
// Disable the login button to prevent multiple submissions
|
||||
$(el_window).find('.login-btn').prop('disabled', true);
|
||||
|
||||
$.ajax({
|
||||
url: `${window.gui_origin }/login`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: headers,
|
||||
contentType: 'application/json',
|
||||
data: data,
|
||||
success: async function (data) {
|
||||
// Keep the button disabled on success since we're redirecting or closing
|
||||
if ( data.next_step === 'otp' ) {
|
||||
const verified = await UIWindowLogin2FA(data.otp_jwt_token);
|
||||
if ( ! verified ) {
|
||||
$(el_window).find('.login-btn').prop('disabled', false);
|
||||
return;
|
||||
}
|
||||
data = verified;
|
||||
}
|
||||
|
||||
await window.update_auth_data(data.token, data.user);
|
||||
|
||||
if ( options.reload_on_success ) {
|
||||
window.onbeforeunload = null;
|
||||
// Replace with a clean URL to prevent password leakage
|
||||
const cleanUrl = options.redirect_url || window.location.origin + window.location.pathname;
|
||||
window.location.replace(cleanUrl);
|
||||
} else
|
||||
{
|
||||
resolve(true);
|
||||
}
|
||||
$(el_window).close();
|
||||
},
|
||||
error: function (err) {
|
||||
// First, ensure URL is clean in case of error (prevent password leakage)
|
||||
if ( window.location.search && (
|
||||
window.location.search.includes('password=') ||
|
||||
window.location.search.includes('username=') ||
|
||||
window.location.search.includes('email=')
|
||||
) ) {
|
||||
const cleanUrl = window.location.origin + window.location.pathname;
|
||||
history.replaceState({}, document.title, cleanUrl);
|
||||
}
|
||||
|
||||
// Enable 'Log In' button
|
||||
$(el_window).find('.login-btn').prop('disabled', false);
|
||||
|
||||
// Handle captcha-specific errors
|
||||
const errorText = err.responseText || '';
|
||||
|
||||
// Try to parse error as JSON
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
|
||||
// If it's a message in the JSON, use that
|
||||
if ( errorJson.message ) {
|
||||
$(el_window).find('.login-error-msg').html(errorJson.message);
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue with text analysis
|
||||
}
|
||||
|
||||
// Fall back to original error handling
|
||||
const $errorMessage = $(el_window).find('.login-error-msg');
|
||||
if ( err.status === 404 ) {
|
||||
// Don't include the whole 404 page
|
||||
$errorMessage.html(`Error 404: "${window.gui_origin}/login" not found`);
|
||||
} else if ( err.responseText ) {
|
||||
$errorMessage.html(html_encode(err.responseText));
|
||||
} else {
|
||||
// No message was returned. *Probably* this means we couldn't reach the server.
|
||||
// If this is a self-hosted instance, it's probably a configuration issue.
|
||||
if ( window.app_domain !== 'puter.com' ) {
|
||||
$errorMessage.html(`<div style="text-align: left;">
|
||||
<p>Error reaching "${window.gui_origin}/login". This is likely to be a configuration issue.</p>
|
||||
<p>Make sure of the following:</p>
|
||||
<ul style="padding-left: 2em;">
|
||||
<li><code>domain</code> in config.json is set to the domain you're using to access puter</li>
|
||||
<li>DNS resolves for the domain, and the <code>api.</code> subdomain on that domain</li>
|
||||
<li><code>http_port</code> is set to the port Puter is listening on (<code>auto</code> will use <code>4100</code> unless that port is in use)</li>
|
||||
<li><code>pub_port</code> is set to the external port (ex: <code>443</code> if you're using a reverse proxy that serves over https)</li>
|
||||
</ul>
|
||||
</div>`);
|
||||
} else {
|
||||
$errorMessage.html(`Failed to log in: Error ${html_encode(err.status)}`);
|
||||
}
|
||||
}
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$(el_window).find('.login-form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Instead of triggering the click event, process the login directly
|
||||
const email_username = $(el_window).find('.email_or_username').val();
|
||||
const password = $(el_window).find('.password').val();
|
||||
|
||||
// Basic validation
|
||||
if ( ! email_username ) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('login_email_username_required'));
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! password ) {
|
||||
$(el_window).find('.login-error-msg').html(i18n('password_required') || 'Password is required');
|
||||
$(el_window).find('.login-error-msg').fadeIn();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process login using the same function as the button click
|
||||
$(el_window).find('.login-btn').click();
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$(el_window).find('.signup-c2a-clickable').on('click', async function (e) {
|
||||
//destroy this window
|
||||
$(el_window).close();
|
||||
// create Signup window
|
||||
const signup = await UIWindowSignup({
|
||||
referrer: options.referrer,
|
||||
show_close_button: options.show_close_button,
|
||||
reload_on_success: options.reload_on_success,
|
||||
redirect_url: options.redirect_url,
|
||||
window_options: options.window_options,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
});
|
||||
if ( signup )
|
||||
{
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
$(el_window).find(`#toggle-show-password-${internal_id}`).on('click', function (e) {
|
||||
options.show_password = !options.show_password;
|
||||
// hide/show password and update icon
|
||||
$(el_window).find('.password').attr('type', options.show_password ? 'text' : 'password');
|
||||
$(el_window).find('.toggle-show-password-icon').attr('src', options.show_password ? window.icons['eye-closed.svg'] : window.icons['eye-open.svg']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowLogin;
|
||||
@@ -1,118 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ el: null }));
|
||||
vi.mock('./UIWindowRecoverPassword.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/authLogoHeader.js', () => ({
|
||||
authLogoHeader: () => '',
|
||||
wireAuthLogoHeader: vi.fn(),
|
||||
}));
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: async (options) => {
|
||||
state.el = document.createElement('div');
|
||||
state.el.innerHTML = options.body_content;
|
||||
document.body.append(state.el);
|
||||
options.onAppend?.(state.el);
|
||||
return state.el;
|
||||
},
|
||||
}));
|
||||
globalThis.def = globalThis.def ?? ((value) => value);
|
||||
const { default: UIWindowLogin } = await import('./UIWindowLogin.js');
|
||||
const { default: UIWindowSignup } = await import('./UIWindowSignup.js');
|
||||
|
||||
const PROVIDERS_URL = 'https://api.puter.test/auth/oidc/providers';
|
||||
const RELAY_POPUP =
|
||||
'embedded_in_popup=true&msg_id=1&cross_origin_isolated=true&signin_session=s';
|
||||
const PLAIN_POPUP = 'embedded_in_popup=true&msg_id=1';
|
||||
|
||||
const asPopup = (query) => {
|
||||
window.embedded_in_popup = true;
|
||||
window.gui_action = 'sign-in';
|
||||
window.url_query_params = new URLSearchParams(query);
|
||||
};
|
||||
|
||||
const askedForProviders = () =>
|
||||
fetch.mock.calls.some(([url]) => url === PROVIDERS_URL);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = vi.fn();
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response(JSON.stringify({ providers: ['google'] })),
|
||||
);
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.gui_params = { turnstileSiteKey: 'site-key' };
|
||||
window.turnstile = { render: vi.fn() };
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => 'oidc-popup-test';
|
||||
window.embedded_in_popup = false;
|
||||
window.gui_action = undefined;
|
||||
window.url_query_params = new URLSearchParams('');
|
||||
state.el = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
[
|
||||
'login',
|
||||
() => UIWindowLogin({ reload_on_success: false, redirect_url: '/' }),
|
||||
'.login-btn',
|
||||
],
|
||||
[
|
||||
'signup',
|
||||
() => UIWindowSignup({ reload_on_success: false, redirect_url: '/' }),
|
||||
'.signup-btn',
|
||||
],
|
||||
])('OIDC buttons in the %s window', (_name, open, formButton) => {
|
||||
const rendered = async () => {
|
||||
void open();
|
||||
await vi.waitFor(() =>
|
||||
expect(jQuery(state.el).find(formButton)).toHaveLength(1),
|
||||
);
|
||||
};
|
||||
|
||||
// The provider's return drops the relay that hands the token to an
|
||||
// isolated opener, so only password sign-in can finish there.
|
||||
it('hides them in a cross-origin-isolated relay popup', async () => {
|
||||
asPopup(RELAY_POPUP);
|
||||
await rendered();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(askedForProviders()).toBe(false);
|
||||
expect(jQuery(state.el).find('.oidc-google-btn').css('display')).toBe(
|
||||
'none',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps them in a regular sign-in popup', async () => {
|
||||
asPopup(PLAIN_POPUP);
|
||||
await rendered();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
jQuery(state.el).find('.oidc-google-btn').css('display'),
|
||||
).toBe('flex'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps them outside a popup', async () => {
|
||||
await rendered();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
jQuery(state.el).find('.oidc-google-btn').css('display'),
|
||||
).toBe('flex'),
|
||||
);
|
||||
expect(askedForProviders()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,474 +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/>.
|
||||
*/
|
||||
|
||||
import TeePromise from '../util/TeePromise.js';
|
||||
import UIWindow from './UIWindow.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 login2faCssInjected = false;
|
||||
const injectLogin2faCss = () => {
|
||||
if ( login2faCssInjected ) return;
|
||||
login2faCssInjected = true;
|
||||
$('<style/>').text(LOGIN_2FA_CSS).appendTo('head');
|
||||
};
|
||||
|
||||
/** Verify an existing first-factor challenge with TOTP or a recovery code. */
|
||||
export default async function UIWindowLogin2FA (challengeToken) {
|
||||
injectLogin2faCss();
|
||||
const p = new TeePromise();
|
||||
let win;
|
||||
|
||||
// ── Build 2FA verification HTML ──
|
||||
let h2fa = '';
|
||||
h2fa += '<div class="login-2fa">';
|
||||
|
||||
// ── 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>';
|
||||
|
||||
// ── 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')}</span></div>`;
|
||||
h2fa += `<button type="button" class="login-2fa-link-btn login-2fa-to-recovery">${i18n('login2fa_use_recovery_code')}</button>`;
|
||||
h2fa += '</div>';
|
||||
|
||||
// ── 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>';
|
||||
|
||||
h2fa += '</div>';
|
||||
|
||||
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_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,
|
||||
window_class: 'window-login-2fa',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': '#f8fafc',
|
||||
padding: '32px 28px 24px',
|
||||
},
|
||||
on_close: () => p.resolve(null),
|
||||
});
|
||||
|
||||
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: challengeToken,
|
||||
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');
|
||||
}
|
||||
$w.find('.login-2fa-spinner').removeClass('visible');
|
||||
p.resolve(next_data);
|
||||
$(win).close();
|
||||
} 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 () {
|
||||
// Codes are 8-char base32; a pasted one usually
|
||||
// carries the newline it was copied with.
|
||||
const value = $(this).val().trim().toUpperCase();
|
||||
if ( value.length !== 8 ) return;
|
||||
let error_i18n_key = 'something_went_wrong';
|
||||
try {
|
||||
// Root origin, like `/login/otp` above: the
|
||||
// route is `guiOriginOnly` and carries no
|
||||
// `subdomain`, so on `api_origin` the
|
||||
// subdomain gate skips it and Express 404s.
|
||||
const resp = await fetch(`${window.gui_origin}/login/recovery-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: challengeToken,
|
||||
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');
|
||||
}
|
||||
p.resolve(next_data);
|
||||
$(win).close();
|
||||
} catch (e) {
|
||||
$w.find('.login-2fa-recovery-error').text(i18n(error_i18n_key)).show();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus first OTP input
|
||||
setTimeout(() => $inputs.first().focus(), 150);
|
||||
return p;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ el: null, options: null }));
|
||||
vi.mock('./UIWindowRecoverPassword.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowSignup.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/authLogoHeader.js', () => ({ authLogoHeader: () => '', wireAuthLogoHeader: vi.fn() }));
|
||||
vi.mock('./UIWindow.js', () => ({ default: async options => {
|
||||
state.options = options;
|
||||
state.el = document.createElement('div');
|
||||
state.el.innerHTML = options.body_content;
|
||||
document.body.append(state.el);
|
||||
options.onAppend?.(state.el);
|
||||
return state.el;
|
||||
} }));
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
const { default: UIWindowLogin2FA } = await import('./UIWindowLogin2FA.js');
|
||||
const { default: UIWindowLogin } = await import('./UIWindowLogin.js');
|
||||
const reply = (status, data) => new Response(JSON.stringify(data), { status });
|
||||
const completed = { proceed: true, next_step: 'complete', token: 'gui-token', user: { username: 'user' }, redirectUrl: 'https://puter.test/dashboard' };
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = vi.fn(function () { state.options.on_close?.(); });
|
||||
globalThis.i18n = key => key;
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.fetch = vi.fn();
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => 'login-test';
|
||||
window.is_email = () => false;
|
||||
window.update_auth_data = vi.fn();
|
||||
state.el = null;
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
const open = async () => {
|
||||
const result = UIWindowLogin2FA('first-factor-challenge');
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-code-inputs input')).toHaveLength(6));
|
||||
return { result };
|
||||
};
|
||||
const enterOtp = code => {
|
||||
const inputs = jQuery(state.el).find('.login-2fa-code-inputs input');
|
||||
[...code].forEach((digit, index) => inputs.eq(index).val(digit));
|
||||
inputs.last().trigger('input');
|
||||
};
|
||||
|
||||
describe('shared login second-factor dialog', () => {
|
||||
it('passes leading-zero codes to the existing verifier and preserves continuation data', async () => {
|
||||
fetch.mockResolvedValue(reply(200, completed));
|
||||
const { result } = await open();
|
||||
enterOtp('001234');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(fetch).toHaveBeenCalledWith('https://puter.test/login/otp', expect.objectContaining({ body: JSON.stringify({ token: 'first-factor-challenge', code: '001234' }) }));
|
||||
expect(jQuery.fn.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps failed challenges available for a recovery-code retry', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { proceed: false })).mockResolvedValueOnce(reply(200, completed));
|
||||
const { result } = await open();
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-error').text()).toBe('confirm_code_generic_incorrect'));
|
||||
expect(jQuery.fn.close).not.toHaveBeenCalled();
|
||||
jQuery(state.el).find('.login-2fa-to-recovery').trigger('click');
|
||||
jQuery(state.el).find('.login-2fa-recovery-input').val(' abcdefg2\n').trigger('input');
|
||||
await expect(result).resolves.toEqual(completed);
|
||||
expect(fetch.mock.calls[1][0]).toBe('https://puter.test/login/recovery-code');
|
||||
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ token: 'first-factor-challenge', code: 'ABCDEFG2' });
|
||||
});
|
||||
|
||||
it('reports throttling without closing or completing login', async () => {
|
||||
fetch.mockResolvedValue(reply(429, {}));
|
||||
const { result } = await open();
|
||||
enterOtp('001234');
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-error').text()).toBe('confirm_code_generic_too_many_requests'));
|
||||
expect(jQuery.fn.close).not.toHaveBeenCalled();
|
||||
state.options.on_close();
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('closing the challenge cancels without authenticating', async () => {
|
||||
const { result } = await open();
|
||||
state.options.on_close();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores incomplete recovery codes', async () => {
|
||||
const { result } = await open();
|
||||
jQuery(state.el).find('.login-2fa-recovery-input').val('ABC').trigger('input');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
state.options.on_close();
|
||||
await expect(result).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('password login using the shared second-factor dialog', () => {
|
||||
it('completes password login only after its challenge succeeds', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { providers: [] })).mockResolvedValueOnce(reply(200, completed));
|
||||
let success;
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation(options => { success = options.success; });
|
||||
const result = UIWindowLogin({ reload_on_success: false, redirect_url: '/' });
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-btn')).toHaveLength(1));
|
||||
const login = state.el;
|
||||
jQuery(login).find('.email_or_username').val('user');
|
||||
jQuery(login).find('.password').val('correct-password');
|
||||
jQuery(login).find('.login-btn').trigger('click');
|
||||
expect(success).toBeTypeOf('function');
|
||||
const submitted = success({ proceed: true, next_step: 'otp', otp_jwt_token: 'password-challenge' });
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-code-inputs input')).toHaveLength(6));
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
enterOtp('001234');
|
||||
await submitted;
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(window.update_auth_data).toHaveBeenCalledWith(completed.token, completed.user);
|
||||
expect(JSON.parse(fetch.mock.calls[1][1].body).token).toBe('password-challenge');
|
||||
});
|
||||
|
||||
it('returns to the enabled password form when the second-factor dialog is dismissed', async () => {
|
||||
fetch.mockResolvedValue(reply(200, { providers: [] }));
|
||||
let success;
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation(options => { success = options.success; });
|
||||
const result = UIWindowLogin({ reload_on_success: false, redirect_url: '/' });
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-btn')).toHaveLength(1));
|
||||
const login = state.el;
|
||||
const loginOptions = state.options;
|
||||
jQuery(login).find('.email_or_username').val('user');
|
||||
jQuery(login).find('.password').val('correct-password');
|
||||
jQuery(login).find('.login-btn').trigger('click');
|
||||
const submitted = success({ proceed: true, next_step: 'otp', otp_jwt_token: 'password-challenge' });
|
||||
await vi.waitFor(() => expect(jQuery(state.el).find('.login-2fa-code-inputs input')).toHaveLength(6));
|
||||
state.options.on_close();
|
||||
await submitted;
|
||||
expect(window.update_auth_data).not.toHaveBeenCalled();
|
||||
expect(jQuery(login).find('.login-btn').prop('disabled')).toBe(false);
|
||||
loginOptions.on_close();
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIAlert from './UIAlert.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import check_password_strength from '../helpers/checkPasswordStrength.js';
|
||||
|
||||
async function UIWindowNewPassword (options) {
|
||||
@@ -183,11 +183,9 @@ async function UIWindowNewPassword (options) {
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
},
|
||||
dismissable: false,
|
||||
});
|
||||
},
|
||||
error: function (err) {
|
||||
|
||||
@@ -1,138 +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/>.
|
||||
*/
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIAlert from './UIAlert.js';
|
||||
|
||||
function UIWindowRecoverPassword (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
options = options ?? {};
|
||||
|
||||
let h = '';
|
||||
h += '<div style="-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #3e5362;">';
|
||||
h += `<h3 style="text-align:center; font-weight: 400; font-size: 20px;">${i18n('recover_password')}</h3>`;
|
||||
h += '<form class="pass-recovery-form">';
|
||||
h += '<p style="text-align:center; padding: 0 20px;"></p>';
|
||||
h += '<div class="error"></div>';
|
||||
h += `<label>${i18n('email_or_username')}</label>`;
|
||||
h += '<input class="pass-recovery-username-or-email" type="text"/>';
|
||||
h += `<button type="submit" class="send-recovery-email button button-block button-primary" style="margin-top:10px;">${i18n('send_password_recovery_email')}</button>`;
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: null,
|
||||
backdrop: options.backdrop ?? false,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: options.has_head ?? true,
|
||||
selectable_body: false,
|
||||
draggable_body: true,
|
||||
allow_context_menu: false,
|
||||
is_draggable: options.is_draggable ?? true,
|
||||
is_droppable: false,
|
||||
is_resizable: false,
|
||||
stay_on_top: options.stay_on_top ?? false,
|
||||
allow_native_ctxmenu: true,
|
||||
allow_user_select: true,
|
||||
width: 350,
|
||||
dominant: true,
|
||||
...options.window_options,
|
||||
onAppend: function (el_window) {
|
||||
$(el_window).find('.pass-recovery-username-or-email').first().focus();
|
||||
},
|
||||
window_class: 'window-recover-password',
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
},
|
||||
body_css: {
|
||||
padding: '10px',
|
||||
width: 'initial',
|
||||
height: 'initial',
|
||||
'background-color': 'rgba(231, 238, 245)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
},
|
||||
});
|
||||
$(el_window).find('.pass-recovery-form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
// Send recovery email
|
||||
$(el_window).find('.send-recovery-email').on('click', function (e) {
|
||||
let email, username;
|
||||
let input = $(el_window).find('.pass-recovery-username-or-email').val();
|
||||
if ( window.is_email(input) )
|
||||
{
|
||||
email = input;
|
||||
}
|
||||
else
|
||||
{
|
||||
username = input;
|
||||
}
|
||||
|
||||
// todo validation before sending
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/send-pass-recovery-email`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
email: email,
|
||||
username: username,
|
||||
}),
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: async function (res) {
|
||||
$(el_window).close();
|
||||
await UIAlert({
|
||||
message: res.message,
|
||||
body_icon: window.icons['c-check.svg'],
|
||||
stay_on_top: true,
|
||||
backdrop: true,
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
error: function (err) {
|
||||
const errorText = err.responseText || '';
|
||||
let msg = errorText;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
msg = errorJson.message || errorJson.error || errorText;
|
||||
} catch (_) { /* not JSON, use responseText */ }
|
||||
$(el_window).find('.error').html(html_encode(msg));
|
||||
$(el_window).find('.error').fadeIn();
|
||||
},
|
||||
complete: function () {
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowRecoverPassword;
|
||||
@@ -1,186 +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/>.
|
||||
*/
|
||||
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import UIWindowSignup from './UIWindowSignup.js';
|
||||
|
||||
async function UIWindowSessionList (options) {
|
||||
options = options ?? {};
|
||||
options.reload_on_success = options.reload_on_success ?? true;
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
let h = '';
|
||||
h += '<div style="margin:10px;">';
|
||||
// loading indicator with spinner
|
||||
h += `<div class="loading" style="z-index: 999999999;">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 15px;">
|
||||
<div style="
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
"></div>
|
||||
<div style="
|
||||
font-size: 15px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
">${i18n('signing_in')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
// session list
|
||||
h += '<div class="hide-scrollbar" style="overflow-y: scroll; max-width: 400px; margin: 0 auto;">';
|
||||
h += `<h1 style="text-align: center; font-size: 18px; font-weight: normal; color: #757575; margin-bottom: 30px;"><img src="${window.icons['logo-white.svg']}" style="padding: 4px; background-color: blue; border-radius: 5px; width: 25px; box-sizing: border-box; margin-bottom: -6px; margin-right: 6px;">${i18n('sign_in_with_puter')}</h1>`;
|
||||
for ( let index = 0; index < window.logged_in_users.length; index++ ) {
|
||||
const l_user = window.logged_in_users[index];
|
||||
h += `<div data-uuid="${l_user.uuid}" class="session-entry" style="display: flex; padding: 15px 10px;">`;
|
||||
// profile picture
|
||||
h += `<div class="profile-picture" style="background-color: #cbced1; width: 30px; height: 30px; margin:0; margin-right: 10px; background-image: url('${l_user.profile.picture ?? window.icons['profile.svg']}');"></div>`;
|
||||
h += `<div style="display: flex; align-items: center;">${l_user.username}</div>`;
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
// c2a
|
||||
h += `<div style="margin-top: 20px; margin-bottom: 20px; text-align:center;"><span class="login-c2a-session-list">Log Into Another Account</span> • <span class="signup-c2a-session-list">${i18n('create_account')}</span></div>`;
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: i18n('window_title_session_list'),
|
||||
app: 'session-list',
|
||||
single_instance: true,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: false,
|
||||
selectable_body: false,
|
||||
draggable_body: options.draggable_body ?? true,
|
||||
allow_context_menu: false,
|
||||
is_resizable: false,
|
||||
is_droppable: false,
|
||||
init_center: true,
|
||||
allow_native_ctxmenu: false,
|
||||
allow_user_select: false,
|
||||
width: 350,
|
||||
height: 'auto',
|
||||
dominant: true,
|
||||
show_in_taskbar: false,
|
||||
update_window_url: false,
|
||||
cover_page: options.cover_page ?? false,
|
||||
window_class: 'window-session-list',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': 'rgb(245 247 249)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'justify-content': 'center',
|
||||
},
|
||||
});
|
||||
$(el_window).find('.login-c2a-session-list').on('click', async function (e) {
|
||||
// The login window is a centered cover and this picker would
|
||||
// otherwise float on top of it, hiding its username field. Only
|
||||
// the reload flows close it: in the no-reload (popup) flows the
|
||||
// picker doubles as the fallback UI when the login window is
|
||||
// abandoned, and the resolve() below still needs a live promise
|
||||
// chain either way.
|
||||
if ( options.reload_on_success ) $(el_window).close();
|
||||
const login = await UIWindowLogin({
|
||||
referrer: options.referrer,
|
||||
reload_on_success: options.reload_on_success,
|
||||
cover_page: options.cover_page ?? false,
|
||||
has_head: options.has_head,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: options.cover_page ?? false,
|
||||
},
|
||||
});
|
||||
if ( login ) {
|
||||
if ( options.reload_on_success ) {
|
||||
// disable native browser exit confirmation
|
||||
window.onbeforeunload = null;
|
||||
// refresh
|
||||
location.reload();
|
||||
} else {
|
||||
resolve(login);
|
||||
}
|
||||
}
|
||||
});
|
||||
$(el_window).find('.signup-c2a-session-list').on('click', async function (e) {
|
||||
// Same picker-over-cover overlap as the login c2a above, same
|
||||
// reload-flows-only close. (The selector this replaces —
|
||||
// '.signup-c2a-clickable', the LOGIN window's c2a class —
|
||||
// matched nothing in this window and closed nothing.)
|
||||
if ( options.reload_on_success ) $(el_window).close();
|
||||
// create Signup window
|
||||
const signup = await UIWindowSignup({
|
||||
referrer: options.referrer,
|
||||
reload_on_success: options.reload_on_success,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: options.cover_page ?? false,
|
||||
},
|
||||
|
||||
});
|
||||
if ( signup ) {
|
||||
if ( options.reload_on_success ) {
|
||||
// disable native browser exit confirmation
|
||||
window.onbeforeunload = null;
|
||||
// refresh
|
||||
location.reload();
|
||||
} else {
|
||||
resolve(signup);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(el_window).find('.session-entry').on('click', function (e) {
|
||||
$(el_window).find('.loading').css({ display: 'flex' });
|
||||
|
||||
setTimeout(async () => {
|
||||
let selected_uuid = $(this).attr('data-uuid');
|
||||
let selected_user;
|
||||
for ( let index = 0; index < window.logged_in_users.length; index++ ) {
|
||||
const l_user = window.logged_in_users[index];
|
||||
if ( l_user.uuid === selected_uuid ) {
|
||||
selected_user = l_user;
|
||||
}
|
||||
}
|
||||
|
||||
// new logged in user
|
||||
await window.update_auth_data(selected_user.auth_token, selected_user);
|
||||
if ( options.reload_on_success ) {
|
||||
// disable native browser exit confirmation
|
||||
window.onbeforeunload = null;
|
||||
// refresh
|
||||
location.reload();
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowSessionList;
|
||||
@@ -1,861 +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/>.
|
||||
*/
|
||||
|
||||
import check_password_strength from '../helpers/checkPasswordStrength.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js';
|
||||
import UIWindowPhoneVerificationRequired from './UIWindowPhoneVerificationRequired.js';
|
||||
import UIWindowCardVerificationRequired from './UIWindowCardVerificationRequired.js';
|
||||
import UIWindowLogin from './UIWindowLogin.js';
|
||||
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../util/openid.js';
|
||||
import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
|
||||
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedirect.js';
|
||||
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
|
||||
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
|
||||
import { bonusRequirementsKey, checkSignupBonusCode } from '../helpers/signupBonusCode.js';
|
||||
|
||||
// What a checked bonus code grants, above the form (and the provider buttons).
|
||||
function renderBonusNotice(el_window, result) {
|
||||
const $notice = $(el_window).find('.signup-bonus-notice');
|
||||
if (!result) {
|
||||
$notice.hide().empty();
|
||||
return;
|
||||
}
|
||||
if (!result.valid) {
|
||||
$notice
|
||||
.addClass('signup-bonus-notice-invalid')
|
||||
.html(`<p>${i18n('signup_bonus_code_invalid_retry')}</p>`)
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
const requirementsKey = bonusRequirementsKey(result.requirements);
|
||||
let h = `<strong>${html_encode(result.display.title)}</strong>`;
|
||||
h += `<p>${html_encode(result.display.description)}</p>`;
|
||||
if (requirementsKey) {
|
||||
h += `<p class="signup-bonus-requirements">${i18n(requirementsKey)}</p>`;
|
||||
}
|
||||
$notice.removeClass('signup-bonus-notice-invalid').html(h).show();
|
||||
}
|
||||
|
||||
function UIWindowSignup(options) {
|
||||
options = options ?? {};
|
||||
options.reload_on_success = options.reload_on_success ?? true;
|
||||
options.has_head = options.has_head ?? true;
|
||||
options.send_confirmation_code = options.send_confirmation_code ?? false;
|
||||
options.show_close_button = options.show_close_button ?? true;
|
||||
if (options.redirect_url === undefined) {
|
||||
// stay on the page the signup started from (e.g. an /app/<name>
|
||||
// landing), with standalone auth pages going to the root dashboard
|
||||
options.redirect_url = get_auth_redirect_url();
|
||||
}
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const internal_id = window.uuidv4();
|
||||
|
||||
let offersProviders = false;
|
||||
// Form signups are closed, so the providers are the only way in.
|
||||
const showOidcOnly = (el_window) => {
|
||||
$(el_window).find('.signup-form').hide();
|
||||
$(el_window).find('.signup-with-email-wrapper').hide();
|
||||
$(el_window).find('.oidc-providers-wrapper').show();
|
||||
$(el_window).find('.signup-oidc-only-notice').show();
|
||||
};
|
||||
|
||||
const logo_clickable =
|
||||
!!options.window_options?.cover_page && !window.embedded_in_popup;
|
||||
|
||||
let h = '';
|
||||
h +=
|
||||
'<div style="margin: 0 auto;">';
|
||||
// logo
|
||||
h += authLogoHeader({
|
||||
logoSrc: window.icons['logo-white.svg'],
|
||||
logoClickable: logo_clickable,
|
||||
openerOrigin: window.embedded_in_popup && isAttestedOrigin(window.openerOrigin) ? window.openerOrigin : '',
|
||||
openerFallbackSrc: window.icons['website.svg'],
|
||||
});
|
||||
// close button
|
||||
if (!options.has_head && options.show_close_button !== false) {
|
||||
h += '<div class="generic-close-window-button"> × </div>';
|
||||
}
|
||||
|
||||
// Form
|
||||
h += '<div style="padding: 15px;">';
|
||||
|
||||
// title
|
||||
h += `<h1 class="signup-form-title">${i18n('create_free_account')}</h1>`;
|
||||
// In a sign-in popup, say which site brought the user here.
|
||||
if (window.embedded_in_popup && isAttestedOrigin(window.openerOrigin)) {
|
||||
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(window.openerOrigin).hostname])}</p>`;
|
||||
}
|
||||
h += '<div class="signup-bonus-notice" style="display:none;"></div>';
|
||||
// signup form
|
||||
h += '<form class="signup-form">';
|
||||
// error msg
|
||||
h += '<div class="signup-error-msg"></div>';
|
||||
// username
|
||||
h += '<div style="overflow: hidden;">';
|
||||
h += `<label for="username-${internal_id}">${i18n('username')}</label>`;
|
||||
h += `<input id="username-${internal_id}" value="${html_encode(options.username ?? '')}" class="username" type="text" autocomplete="username" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false"/>`;
|
||||
h += '</div>';
|
||||
// email
|
||||
h += '<div style="overflow: hidden; margin-top: 10px;">';
|
||||
h += `<label for="email-${internal_id}">${i18n('email')}</label>`;
|
||||
h += `<input id="email-${internal_id}" value="${html_encode(options.email ?? '')}" class="email" type="email" autocomplete="email" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false"/>`;
|
||||
h += '</div>';
|
||||
// password
|
||||
h +=
|
||||
'<div style="overflow: hidden; margin-top: 10px; position: relative;">';
|
||||
h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
|
||||
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? 'text' : 'password'}" name="password" autocomplete="new-password" />`;
|
||||
// show/hide icon
|
||||
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
|
||||
<img class="toggle-show-password-icon" src="${options.show_password ? window.icons['eye-closed.svg'] : window.icons['eye-open.svg']}" width="20" height="20">
|
||||
</span>`;
|
||||
h += '</div>';
|
||||
// confirm password
|
||||
h +=
|
||||
'<div style="overflow: hidden; margin-top: 10px; margin-bottom: 10px; position: relative">';
|
||||
h += `<label for="confirm-password-${internal_id}">${i18n('signup_confirm_password')}</label>`;
|
||||
h += `<input id="confirm-password-${internal_id}" class="confirm-password" type="${options.show_password ? 'text' : 'password'}" name="confirm-password" autocomplete="new-password" />`;
|
||||
// show/hide icon
|
||||
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
|
||||
<img class="toggle-show-password-icon" src="${options.show_password ? window.icons['eye-closed.svg'] : window.icons['eye-open.svg']}" width="20" height="20">
|
||||
</span>`;
|
||||
h += '</div>';
|
||||
// bot trap - if this value is submitted server will ignore the request
|
||||
h +=
|
||||
'<input type="text" name="p102xyzname" class="p102xyzname" value="">';
|
||||
|
||||
// Turnstile widget (only when enabled)
|
||||
if (window.gui_params?.turnstileSiteKey) {
|
||||
h +=
|
||||
'<div style="min-height: 20px; display: flex; justify-content: center;">';
|
||||
// appearance: always/execute/interaction-only
|
||||
// docs: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/?utm_source=chatgpt.com#appearance-modes
|
||||
h += `<div class="cf-turnstile" data-sitekey="${window.gui_params.turnstileSiteKey}" data-appearance="interaction-only"></div>`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
// Create Account
|
||||
h += `<button class="signup-btn button button-primary button-block button-normal">${i18n('create_free_account')}</button>`;
|
||||
h += '</form>';
|
||||
h +=
|
||||
'<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
|
||||
h += `<p class="signup-oidc-only-notice" style="display:none;">${i18n('signup_oidc_only')}</p>`;
|
||||
h += `<button type="button" class="oidc-google-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_up_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-microsoft-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_up_with_microsoft')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn oidc-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
|
||||
h += '<div class="oidc-custom-providers"></div>';
|
||||
h += '</div>';
|
||||
// "Sign up with email" button, shown only when OIDC providers are available
|
||||
h += `<div class="signup-with-email-wrapper" style="display:none;">`;
|
||||
h += `<button class="signup-with-email-btn oidc-btn button button-block button-normal">${i18n('sign_up_with_email')}</button>`;
|
||||
h += `</div>`;
|
||||
// terms and privacy
|
||||
h += `<p class="signup-terms">${i18n('tos_fineprint', [], false)}</p>`;
|
||||
h += '</div>';
|
||||
// login link
|
||||
// create account link
|
||||
h += '<div class="c2a-wrapper" style="padding:15px;">';
|
||||
h += `<button class="login-c2a-clickable">${i18n('log_in')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: null,
|
||||
app: 'signup',
|
||||
single_instance: true,
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
draggable_body: false,
|
||||
has_head: true,
|
||||
selectable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_draggable: true,
|
||||
is_droppable: false,
|
||||
is_resizable: false,
|
||||
stay_on_top: false,
|
||||
allow_native_ctxmenu: true,
|
||||
allow_user_select: true,
|
||||
...options.window_options,
|
||||
dominant: true,
|
||||
center: true,
|
||||
onAppend: function (el_window) {
|
||||
if (options.authError) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(html_encode(options.authError))
|
||||
.fadeIn();
|
||||
}
|
||||
if (!window.disable_signup_autofocus) {
|
||||
$(el_window)
|
||||
.find('.username')
|
||||
.get(0)
|
||||
.focus({ preventScroll: true });
|
||||
}
|
||||
wireAuthLogoHeader(el_window);
|
||||
if (logo_clickable) {
|
||||
$(el_window)
|
||||
.find('.auth-logo')
|
||||
.on('click', function () {
|
||||
// keep desktop users on the desktop; everyone else goes to the root dashboard
|
||||
window.location.href =
|
||||
window.location.pathname === '/desktop'
|
||||
? '/desktop'
|
||||
: '/';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Turnstile widget with callback to capture token
|
||||
const initTurnstile = () => {
|
||||
if (
|
||||
window.turnstile &&
|
||||
window.gui_params?.turnstileSiteKey
|
||||
) {
|
||||
window.turnstile.render('.cf-turnstile', {
|
||||
sitekey: window.gui_params.turnstileSiteKey,
|
||||
callback: function (token) {
|
||||
// Store the token for the signup request
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.attr('data-token', token);
|
||||
// Enable the signup button once CAPTCHA is completed
|
||||
$(el_window)
|
||||
.find('.signup-btn')
|
||||
.prop('disabled', false);
|
||||
// Add visual feedback
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.addClass('captcha-completed');
|
||||
},
|
||||
'expired-callback': function () {
|
||||
// Reset when token expires
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.removeAttr('data-token');
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.removeClass('captcha-completed');
|
||||
$(el_window)
|
||||
.find('.signup-btn')
|
||||
.prop('disabled', true);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// If Turnstile isn't loaded yet, wait for it
|
||||
setTimeout(initTurnstile, 100);
|
||||
}
|
||||
};
|
||||
|
||||
initTurnstile();
|
||||
|
||||
if (window.signup_bonus_code) {
|
||||
(async () => {
|
||||
let fingerprint = null;
|
||||
try {
|
||||
fingerprint =
|
||||
await window.getDeviceFingerprint?.();
|
||||
} catch (_) {
|
||||
// the check works without device signals
|
||||
}
|
||||
const result = await checkSignupBonusCode(
|
||||
window.signup_bonus_code,
|
||||
{ origin: window.gui_origin, fingerprint },
|
||||
);
|
||||
// Unknown (null) keeps the code; signup has the final say.
|
||||
if (result && !result.valid) {
|
||||
window.signup_bonus_code = null;
|
||||
}
|
||||
renderBonusNotice(el_window, result);
|
||||
})();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// A federated hop navigates this popup away and the
|
||||
// provider returns it as a plain sign-in popup, losing
|
||||
// whatever the popup was opened to do — and, for a
|
||||
// permission prompt, handing the opener a token instead
|
||||
// of a decision. Don't offer it there. A
|
||||
// cross-origin-isolated opener can only get the token
|
||||
// through the session relay, which the provider's
|
||||
// return doesn't keep.
|
||||
if (
|
||||
window.embedded_in_popup &&
|
||||
(!offersFederatedSignInInPopup(window.gui_action) ||
|
||||
window.url_query_params?.get(
|
||||
'cross_origin_isolated',
|
||||
) === 'true')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(
|
||||
`${window.api_origin}/auth/oidc/providers`,
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data.providers) return;
|
||||
|
||||
const wireOidcBtn = (provider, btnClass) => {
|
||||
$(el_window).find(btnClass).css('display', 'flex');
|
||||
$(el_window)
|
||||
.find(btnClass)
|
||||
.on('click', function () {
|
||||
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=signup`;
|
||||
// return to the interface the signup started from (backend whitelists the path)
|
||||
const return_to = get_oidc_return_to();
|
||||
if (return_to) {
|
||||
url += `&return_to=${encodeURIComponent(return_to)}`;
|
||||
}
|
||||
const referrer =
|
||||
options.referrer ?? window.referrerStr;
|
||||
if (referrer) {
|
||||
url += `&referrer=${encodeURIComponent(referrer)}`;
|
||||
}
|
||||
if (window.signup_bonus_code) {
|
||||
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
|
||||
}
|
||||
if (
|
||||
window.embedded_in_popup &&
|
||||
window.url_query_params?.get('msg_id')
|
||||
) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if (window.openerOrigin) {
|
||||
url += `&opener_origin=${encodeURIComponent(window.openerOrigin)}`;
|
||||
}
|
||||
}
|
||||
window.location.href = url;
|
||||
});
|
||||
};
|
||||
|
||||
let hasProvider = false;
|
||||
if (data.providers.includes('google')) {
|
||||
hasProvider = true;
|
||||
wireOidcBtn('google', '.oidc-google-btn');
|
||||
}
|
||||
if (data.providers.includes('apple')) {
|
||||
hasProvider = true;
|
||||
wireOidcBtn('apple', '.oidc-apple-btn');
|
||||
}
|
||||
if (data.providers.includes('microsoft')) {
|
||||
hasProvider = true;
|
||||
wireOidcBtn('microsoft', '.oidc-microsoft-btn');
|
||||
}
|
||||
|
||||
// Any other configured provider (e.g. a self-hosted
|
||||
// admin's custom OIDC IdP) gets a generic button.
|
||||
data.providers
|
||||
.filter(
|
||||
provider =>
|
||||
!KNOWN_OIDC_PROVIDERS.includes(provider),
|
||||
)
|
||||
.forEach((provider, idx) => {
|
||||
hasProvider = true;
|
||||
const btnClass = `oidc-custom-btn-${idx}`;
|
||||
const label =
|
||||
humanizeOidcProviderId(provider);
|
||||
$(el_window)
|
||||
.find('.oidc-custom-providers')
|
||||
.append(
|
||||
`<button type="button" class="${btnClass} button button-block button-normal" style="display:flex; align-items:center; justify-content:center; gap:8px; margin-top:8px;">${OIDC_GENERIC_PROVIDER_ICON}${i18n('sign_up_with_provider', [label])}</button>`,
|
||||
);
|
||||
wireOidcBtn(provider, `.${btnClass}`);
|
||||
});
|
||||
|
||||
if (hasProvider) {
|
||||
offersProviders = true;
|
||||
$(el_window).find('.oidc-providers-wrapper').show();
|
||||
// Hide the signup form and show the "Sign up with email" button
|
||||
$(el_window).find('.signup-form').hide();
|
||||
$(el_window).find('.signup-form-title').hide();
|
||||
$(el_window)
|
||||
.find('.signup-with-email-wrapper')
|
||||
.show();
|
||||
$(el_window)
|
||||
.find('.signup-with-email-btn')
|
||||
.on('click', function () {
|
||||
$(el_window).find('.signup-form').show();
|
||||
$(el_window)
|
||||
.find('.signup-form-title')
|
||||
.show();
|
||||
$(el_window)
|
||||
.find('.signup-with-email-wrapper')
|
||||
.hide();
|
||||
$(el_window)
|
||||
.find('.username')
|
||||
.get(0)
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (data.signupOidcOnly === true && hasProvider) {
|
||||
showOidcOnly(el_window);
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
},
|
||||
window_class: 'window-signup',
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
},
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
'background-color': 'white',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
'justify-content': 'center',
|
||||
'align-items': 'center',
|
||||
padding: '20px 10px 10px 10px',
|
||||
},
|
||||
// Add custom CSS for CAPTCHA states
|
||||
custom_css: `
|
||||
.cf-turnstile.captcha-completed {
|
||||
border: 2px solid #4CAF50;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
.signup-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
$(el_window)
|
||||
.find('.login-c2a-clickable')
|
||||
.on('click', async function (e) {
|
||||
$('.login-c2a-clickable').parents('.window').close();
|
||||
const login = await UIWindowLogin({
|
||||
referrer: options.referrer,
|
||||
reload_on_success: options.reload_on_success,
|
||||
redirect_url: options.redirect_url,
|
||||
window_options: options.window_options,
|
||||
show_close_button: options.show_close_button,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
show_password: false,
|
||||
});
|
||||
if (login) {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
$(el_window)
|
||||
.find('.signup-btn')
|
||||
.on('click', async function (e) {
|
||||
// Clear previous error states
|
||||
$(el_window).find('.signup-error-msg').hide();
|
||||
|
||||
//Username
|
||||
let username = $(el_window).find('.username').val();
|
||||
|
||||
if (!username) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('username_required'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
//Email
|
||||
let email = $(el_window).find('.email').val();
|
||||
|
||||
// must have an email
|
||||
if (!email) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('email_required'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// must be a valid email
|
||||
else if (!window.is_email(email)) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('email_invalid'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
//Password
|
||||
let password = $(el_window).find('.password').val();
|
||||
|
||||
// must have a password
|
||||
if (!password) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('password_required'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// check password strength
|
||||
const pass_strength = check_password_strength(password);
|
||||
if (!pass_strength.overallPass) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('password_strength_error'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// get confirm password value
|
||||
const confirmPassword = $(el_window)
|
||||
.find('.confirm-password')
|
||||
.val();
|
||||
if (!confirmPassword) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('confirm_password_required'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// check if passwords match
|
||||
if (password !== confirmPassword) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('passwords_do_not_match'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Cloudflare Turnstile CAPTCHA was completed
|
||||
let turnstileToken = null;
|
||||
if (window.turnstile && window.gui_params?.turnstileSiteKey) {
|
||||
turnstileToken = $(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.attr('data-token');
|
||||
if (!turnstileToken) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(
|
||||
i18n('captcha_required') ||
|
||||
'Please complete the CAPTCHA verification',
|
||||
);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//xyzname
|
||||
let p102xyzname = $(el_window).find('.p102xyzname').val();
|
||||
|
||||
// disable 'Create Account' button
|
||||
$(el_window).find('.signup-btn').prop('disabled', true);
|
||||
|
||||
let headers = {};
|
||||
if (window.custom_headers) {
|
||||
headers = window.custom_headers;
|
||||
}
|
||||
|
||||
// Device signal for abuse prevention; omitted when unavailable
|
||||
let fingerprint = null;
|
||||
try {
|
||||
fingerprint = await window.getDeviceFingerprint?.();
|
||||
} catch (_) {
|
||||
// signup must never block or fail because of device signals
|
||||
}
|
||||
|
||||
// Include captcha in request only if required
|
||||
const requestData = {
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
referrer: options.referrer ?? window.referrerStr,
|
||||
send_confirmation_code: options.send_confirmation_code,
|
||||
p102xyzname: p102xyzname,
|
||||
'cf-turnstile-response': turnstileToken,
|
||||
};
|
||||
if (fingerprint) {
|
||||
requestData.fingerprint = fingerprint;
|
||||
}
|
||||
if (window.signup_bonus_code) {
|
||||
requestData.bonusCode = window.signup_bonus_code;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: `${window.gui_origin}/signup`,
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: headers,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(requestData),
|
||||
success: async function (data) {
|
||||
await window.update_auth_data(data.token, data.user);
|
||||
|
||||
let email_verified = true;
|
||||
const show_email_dialog =
|
||||
!options.reload_on_success &&
|
||||
(options.send_confirmation_code ||
|
||||
data.user?.requires_email_confirmation);
|
||||
if (
|
||||
data.user?.requires_phone_verification ||
|
||||
data.user?.requires_card_verification ||
|
||||
show_email_dialog
|
||||
) {
|
||||
$(el_window).close();
|
||||
// Verification gates run in the server's order
|
||||
// (see assertVerifiedAccount): email → phone
|
||||
// (SMS) → card, or card → phone → email when the
|
||||
// account clears email last.
|
||||
const emailGate = async () => {
|
||||
if (show_email_dialog) {
|
||||
email_verified =
|
||||
await UIWindowEmailConfirmationRequired({
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
reload_on_success:
|
||||
options.reload_on_success,
|
||||
window_options:
|
||||
options.window_options ?? {},
|
||||
});
|
||||
}
|
||||
};
|
||||
// A user the SMS path keeps failing may take the
|
||||
// card fallback instead; that resolves 'card' and
|
||||
// clears the card gate along with the phone gate
|
||||
// (see UIWindowPhoneVerificationRequired).
|
||||
let card_gate_cleared_by_fallback = false;
|
||||
const phoneGate = async () => {
|
||||
if (!data.user?.requires_phone_verification)
|
||||
return;
|
||||
let phone_ok = false;
|
||||
do {
|
||||
phone_ok =
|
||||
await UIWindowPhoneVerificationRequired(
|
||||
{
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
window_options:
|
||||
options.window_options ??
|
||||
{},
|
||||
},
|
||||
);
|
||||
} while (!phone_ok);
|
||||
card_gate_cleared_by_fallback =
|
||||
phone_ok === 'card';
|
||||
};
|
||||
const cardGate = async () => {
|
||||
if (
|
||||
!data.user?.requires_card_verification ||
|
||||
card_gate_cleared_by_fallback
|
||||
)
|
||||
return;
|
||||
let card_ok = false;
|
||||
do {
|
||||
card_ok =
|
||||
await UIWindowCardVerificationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
window_options:
|
||||
options.window_options ?? {},
|
||||
});
|
||||
} while (!card_ok);
|
||||
};
|
||||
if (data.user?.email_gate_last) {
|
||||
await cardGate();
|
||||
await phoneGate();
|
||||
await emailGate();
|
||||
} else {
|
||||
await emailGate();
|
||||
await phoneGate();
|
||||
await cardGate();
|
||||
}
|
||||
}
|
||||
|
||||
if (options.reload_on_success) {
|
||||
window.onbeforeunload = null;
|
||||
// Signup can interrupt a direct app landing (the
|
||||
// login cover page's "Sign up", the session
|
||||
// picker's "Create Account", the
|
||||
// must_login_or_signup fallback) — landing on '/'
|
||||
// afterwards would silently drop the app the URL
|
||||
// asked for, so app landings are preserved and
|
||||
// the reloaded boot replays them, launch and
|
||||
// dashboard intro included (the query string is
|
||||
// deliberately left behind, mirroring login's
|
||||
// credential-leak hygiene). Every other route
|
||||
// keeps the historical '/' — notably
|
||||
// /action/signup, where coming back to the same
|
||||
// path would just show this window again.
|
||||
const on_app_landing =
|
||||
/^\/(?:desktop\/)?app\/[^/]+\/?$/.test(
|
||||
window.location.pathname,
|
||||
);
|
||||
const redirectUrl =
|
||||
options.redirect_url ||
|
||||
(on_app_landing
|
||||
? window.location.origin +
|
||||
window.location.pathname
|
||||
: '/');
|
||||
window.location.replace(redirectUrl);
|
||||
} else {
|
||||
resolve(email_verified);
|
||||
}
|
||||
},
|
||||
error: function (err) {
|
||||
// re-enable 'Create Account' button so user can try again
|
||||
$(el_window)
|
||||
.find('.signup-btn')
|
||||
.prop('disabled', false);
|
||||
|
||||
// Reset Turnstile widget for retry
|
||||
try {
|
||||
if (window.turnstile) {
|
||||
window.turnstile?.reset('.cf-turnstile');
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.removeAttr('data-token');
|
||||
$(el_window)
|
||||
.find('.cf-turnstile')
|
||||
.removeClass('captcha-completed');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
// Process error response
|
||||
const errorText = err.responseText || '';
|
||||
|
||||
// Handle JSON error response
|
||||
try {
|
||||
// Try to parse error as JSON
|
||||
const errorJson = JSON.parse(errorText);
|
||||
|
||||
// Handle signup blocked with full-screen overlay
|
||||
if (errorJson?.code === 'signup_blocked') {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('signup-blocked-overlay');
|
||||
const blockedMsg =
|
||||
errorJson.message || 'Signup Not Allowed';
|
||||
overlay.innerHTML = `
|
||||
<div class="signup-blocked-content">
|
||||
<img src="${window.icons['logo.svg'] || window.icons['logo-white.svg'] || ''}" style="width:64px;margin-bottom:24px;" />
|
||||
<p>${html_encode(blockedMsg)}</p>
|
||||
<p>If you already have an account, try <a href="/action/login">logging in</a>. Otherwise, contact <a href="mailto:[email protected]">[email protected]</a> for assistance.</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
return;
|
||||
}
|
||||
|
||||
// Form signups closed after this window loaded.
|
||||
// Without provider buttons (some popups), the
|
||||
// server's message below says it instead.
|
||||
if (
|
||||
errorJson?.code === 'signup_oidc_only' &&
|
||||
offersProviders
|
||||
) {
|
||||
showOidcOnly(el_window);
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the code so the next attempt signs up without it.
|
||||
if (errorJson?.code === 'bonus_code_invalid') {
|
||||
window.signup_bonus_code = null;
|
||||
// The error below says it; the offer no longer applies.
|
||||
renderBonusNotice(el_window, null);
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(i18n('signup_bonus_code_invalid_retry'));
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle timeout specifically
|
||||
if (
|
||||
errorJson?.code === 'response_timeout' ||
|
||||
errorText.includes('timeout')
|
||||
) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(
|
||||
i18n('server_timeout') ||
|
||||
'The server took too long to respond. Please try again.',
|
||||
);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a message in the JSON, use that
|
||||
if (errorJson.message) {
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(errorJson.message);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// Not JSON, continue with text analysis
|
||||
}
|
||||
|
||||
// Default general error handling
|
||||
$(el_window)
|
||||
.find('.signup-error-msg')
|
||||
.html(
|
||||
errorText ||
|
||||
i18n('signup_error') ||
|
||||
'An error occurred during signup. Please try again.',
|
||||
);
|
||||
$(el_window).find('.signup-error-msg').fadeIn();
|
||||
},
|
||||
timeout: 30000, // Add a reasonable timeout
|
||||
});
|
||||
});
|
||||
|
||||
$(el_window)
|
||||
.find('.signup-form')
|
||||
.on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
$(el_window)
|
||||
.find(
|
||||
`#toggle-show-password-${internal_id}, #toggle-show-password-${internal_id}-confirm`,
|
||||
)
|
||||
.on('click', function (e) {
|
||||
// hide/show password/confirm password and update icon
|
||||
let inputField = $(this).siblings('input');
|
||||
let isPasswordVisible = inputField.attr('type') === 'text';
|
||||
inputField.attr(
|
||||
'type',
|
||||
isPasswordVisible ? 'password' : 'text',
|
||||
);
|
||||
$(this)
|
||||
.find('.toggle-show-password-icon')
|
||||
.attr(
|
||||
'src',
|
||||
isPasswordVisible
|
||||
? window.icons['eye-open.svg']
|
||||
: window.icons['eye-closed.svg'],
|
||||
);
|
||||
});
|
||||
|
||||
//remove login window
|
||||
$('.signup-c2a-clickable').parents('.window').close();
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowSignup;
|
||||
@@ -1,100 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ el: null }));
|
||||
vi.mock('./UIWindowLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/authLogoHeader.js', () => ({
|
||||
authLogoHeader: () => '',
|
||||
wireAuthLogoHeader: vi.fn(),
|
||||
}));
|
||||
vi.mock('../helpers/checkPasswordStrength.js', () => ({
|
||||
default: () => ({ overallPass: true }),
|
||||
}));
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: async (options) => {
|
||||
state.el = document.createElement('div');
|
||||
state.el.innerHTML = options.body_content;
|
||||
document.body.append(state.el);
|
||||
options.onAppend?.(state.el);
|
||||
return state.el;
|
||||
},
|
||||
}));
|
||||
globalThis.def = globalThis.def ?? ((value) => value);
|
||||
const { default: UIWindowSignup } = await import('./UIWindowSignup.js');
|
||||
|
||||
const providers = (body) => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(body)));
|
||||
};
|
||||
const find = (selector) => jQuery(state.el).find(selector);
|
||||
const shown = (selector) => find(selector).css('display') !== 'none';
|
||||
|
||||
const open = async () => {
|
||||
void UIWindowSignup({ reload_on_success: false, redirect_url: '/' });
|
||||
await vi.waitFor(() => expect(find('.oidc-google-btn').css('display')).toBe('flex'));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = vi.fn();
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.gui_params = {};
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => 'oidc-only-test';
|
||||
window.is_email = () => true;
|
||||
window.embedded_in_popup = false;
|
||||
window.url_query_params = new URLSearchParams('');
|
||||
state.el = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('signup window when only OIDC signups are open', () => {
|
||||
it('offers the email form behind a button normally', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
await open();
|
||||
|
||||
expect(shown('.signup-with-email-wrapper')).toBe(true);
|
||||
expect(shown('.signup-oidc-only-notice')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the email form and says why', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: true });
|
||||
await open();
|
||||
|
||||
await vi.waitFor(() => expect(shown('.signup-oidc-only-notice')).toBe(true));
|
||||
expect(shown('.signup-form')).toBe(false);
|
||||
expect(shown('.signup-with-email-wrapper')).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to the providers when a form signup is refused for it', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
await open();
|
||||
jQuery.ajax = vi.fn((opts) =>
|
||||
opts.error({
|
||||
responseText: JSON.stringify({
|
||||
code: 'signup_oidc_only',
|
||||
message: 'closed',
|
||||
}),
|
||||
}));
|
||||
|
||||
find('.signup-with-email-btn').trigger('click');
|
||||
find('.username').val('someone');
|
||||
find('.email').val('[email protected]');
|
||||
find('.password').val('a-Strong-pass-1');
|
||||
find('.confirm-password').val('a-Strong-pass-1');
|
||||
find('.signup-btn').trigger('click');
|
||||
|
||||
await vi.waitFor(() => expect(shown('.signup-oidc-only-notice')).toBe(true));
|
||||
expect(shown('.signup-form')).toBe(false);
|
||||
expect(shown('.signup-with-email-wrapper')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10681,6 +10681,714 @@ body.dashboard-mode .notifications-close-all {
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================
|
||||
Auth dialog (login, signup, account picker, password recovery, 2FA)
|
||||
====================================== */
|
||||
|
||||
/* Above every window (phones pin them all at 9999999) and the toolbar, and
|
||||
under the stay-on-top band (99999999 and up) where the account checks a
|
||||
new signup has to pass open as windows. */
|
||||
.dashboard-dialog-overlay.auth-dialog-overlay {
|
||||
z-index: 99999998;
|
||||
}
|
||||
|
||||
/* As a page there is nothing behind the card to go back to. */
|
||||
.dashboard-dialog-overlay.auth-dialog-page {
|
||||
background: var(--dashboard-sidebar-background);
|
||||
}
|
||||
|
||||
.dashboard-dialog.auth-dialog {
|
||||
position: relative;
|
||||
width: min(400px, 100%);
|
||||
color: var(--dashboard-text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.auth-dialog [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Back and ✕ float over the top corners; the logo sits between them. */
|
||||
.auth-dialog-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
pointer-events: none;
|
||||
transition: background-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
/* Scrolled, the screen passes under an opaque bar rather than under the
|
||||
bare buttons. */
|
||||
.auth-dialog-scrolled .auth-dialog-bar:has(> button:not([hidden])) {
|
||||
background: var(--dashboard-card-background);
|
||||
box-shadow: 0 1px 0 var(--dashboard-border);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-body {
|
||||
scroll-padding-top: 52px;
|
||||
}
|
||||
|
||||
.auth-dialog-bar > button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.auth-dialog-bar .dashboard-dialog-close {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.auth-dialog-back {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.auth-dialog-back svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.auth-dialog-back:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.auth-dialog-back:not(:disabled):hover {
|
||||
background: var(--dashboard-hover);
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-dialog-back:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.auth-dialog-back {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-dialog-back::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.auth-dialog .dashboard-dialog-body.auth-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-footer {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-footer-sep {
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
/* Logo strip, title, and the opener notice of a sign-in popup. */
|
||||
.auth-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-logo-link {
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-logo-link:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.auth-dialog .auth-opener-logo {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-opener-logo-fallback,
|
||||
.auth-dialog .auth-opener-logo-img {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-opener-logo-fallback {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-logo-connector {
|
||||
width: 54px;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--dashboard-text-primary);
|
||||
text-shadow: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.auth-subtitle,
|
||||
.auth-dialog .auth-opener-notice {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--dashboard-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-opener-notice strong {
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
|
||||
.auth-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.auth-badge svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn.auth-submit {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.auth-status-success {
|
||||
border-color: var(--dashboard-success-border, transparent);
|
||||
background: var(--dashboard-success-background, transparent);
|
||||
color: var(--dashboard-success-text);
|
||||
}
|
||||
|
||||
.auth-status-help p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.auth-status-help ul {
|
||||
margin: 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.auth-forgot-row,
|
||||
.auth-link-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.auth-link-row {
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
display: inline;
|
||||
padding: 2px 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--dashboard-link);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.auth-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-link:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.auth-link {
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-forgot-row {
|
||||
margin-top: -12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Identity providers */
|
||||
.auth-providers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--dashboard-border);
|
||||
}
|
||||
|
||||
.auth-provider-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn.auth-provider-btn {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
gap: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-dialog-btn.auth-provider-btn:hover {
|
||||
background: var(--dashboard-sidebar-background);
|
||||
}
|
||||
}
|
||||
|
||||
/* The dark sidebar shade is the dark card's own. */
|
||||
@media (hover: hover) and (prefers-color-scheme: dark) {
|
||||
.dashboard-dialog-btn.auth-provider-btn:hover {
|
||||
background: var(--dashboard-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-provider-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.auth-provider-icon img,
|
||||
.auth-provider-icon svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
|
||||
/* Signup */
|
||||
.auth-dialog .auth-screen-status {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.auth-dialog .auth-screen-status:empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-dialog .signup-bonus-notice {
|
||||
margin: 0 0 16px;
|
||||
border: 1px solid var(--dashboard-success-border);
|
||||
border-radius: 8px;
|
||||
background: var(--dashboard-success-background);
|
||||
color: var(--dashboard-success-text);
|
||||
}
|
||||
|
||||
.auth-dialog .signup-bonus-notice-invalid {
|
||||
border-color: var(--dashboard-danger-border);
|
||||
background: var(--dashboard-danger-background);
|
||||
color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.auth-dialog .signup-oidc-only-notice {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
.auth-dialog .oidc-providers-wrapper {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.signup-with-email-wrapper {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* The email form under the provider buttons. */
|
||||
.oidc-providers-wrapper:not([hidden]) ~ .signup-form {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--dashboard-border);
|
||||
}
|
||||
|
||||
.auth-dialog .signup-terms {
|
||||
margin: 18px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dashboard-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-dialog .signup-terms a {
|
||||
color: var(--dashboard-link);
|
||||
}
|
||||
|
||||
.auth-turnstile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-turnstile:has(.cf-turnstile:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cf-turnstile.captcha-completed {
|
||||
border: 2px solid #4caf50;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* Account picker */
|
||||
.auth-accounts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auth-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 12px;
|
||||
background: var(--dashboard-card-background);
|
||||
color: var(--dashboard-text-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.auth-account:not(:disabled):hover {
|
||||
border-color: var(--select-color);
|
||||
background: var(--select-tint-weak);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-account:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--select-color);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.auth-account:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.auth-account:disabled:not(.dashboard-form-busy) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.auth-account-avatar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-account-avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.auth-account-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-account-name,
|
||||
.auth-account-detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-account-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-account-detail {
|
||||
font-size: 12px;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.auth-account-chevron {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.auth-account-chevron svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.auth-account.dashboard-form-busy .auth-account-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-account .auth-account-spinner {
|
||||
margin-right: 0;
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
/* Second factor */
|
||||
.login-2fa-code-inputs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-dialog .login-2fa-code-inputs .dashboard-dialog-input,
|
||||
.auth-dialog .login-2fa-code-inputs .dashboard-dialog-input:focus {
|
||||
width: 46px;
|
||||
height: 54px;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-dialog .dashboard-dialog-input.login-2fa-recovery-input,
|
||||
.auth-dialog .dashboard-dialog-input.login-2fa-recovery-input:focus {
|
||||
height: 50px;
|
||||
text-align: center;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.auth-dialog .login-2fa-code-inputs .login-2fa-wrong {
|
||||
border-color: var(--dashboard-danger-text);
|
||||
animation: auth-shake 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes auth-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); }
|
||||
40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-3px); }
|
||||
80% { transform: translateX(2px); }
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.login-2fa-code-inputs {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.auth-dialog .login-2fa-code-inputs .dashboard-dialog-input,
|
||||
.auth-dialog .login-2fa-code-inputs .dashboard-dialog-input:focus {
|
||||
width: 40px;
|
||||
height: 48px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Recovery email sent */
|
||||
.auth-done {
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
/* Wait on the work in flight rather than act twice. */
|
||||
.auth-dialog-busy .auth-link,
|
||||
.auth-dialog-busy .auth-provider-btn {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Phones (and any popup): a page fills the screen, content centered in it,
|
||||
rather than a card on a page. */
|
||||
@media (max-width: 600px) {
|
||||
/* A bottom sheet spans the screen, like every other dashboard sheet. */
|
||||
.dashboard-dialog.auth-dialog {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-dialog-overlay.auth-dialog-page {
|
||||
align-items: stretch;
|
||||
background: var(--dashboard-card-background);
|
||||
}
|
||||
|
||||
.auth-dialog-page .dashboard-dialog.auth-dialog {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.auth-dialog .dashboard-dialog-body.auth-body {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
/* Two links side by side, not stacked like a dialog's buttons. */
|
||||
.auth-dialog .auth-footer {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.embedded-in-popup .dashboard-dialog-overlay.auth-dialog-page {
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
background: var(--dashboard-card-background);
|
||||
}
|
||||
|
||||
.embedded-in-popup .auth-dialog-page .dashboard-dialog.auth-dialog {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Filling the screen, the content keeps a readable width and sits in the
|
||||
middle of the height it doesn't need. */
|
||||
@media (max-width: 600px) {
|
||||
.auth-dialog-page .auth-body::before,
|
||||
.auth-dialog-page .auth-body::after {
|
||||
content: '';
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.auth-dialog-page .auth-body > * {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.embedded-in-popup .auth-dialog-page .auth-body::before,
|
||||
.embedded-in-popup .auth-dialog-page .auth-body::after {
|
||||
content: '';
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.embedded-in-popup .auth-dialog-page .auth-body > * {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.auth-dialog .login-2fa-code-inputs .login-2fa-wrong {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A person's profile picture, laid over their initial once it loads. The
|
||||
element selector outranks each avatar's own color, dark mode included. */
|
||||
span[data-avatar-user].has-picture {
|
||||
|
||||
@@ -2662,32 +2662,6 @@ label {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.session-entry {
|
||||
cursor: pointer;
|
||||
padding: 20px;
|
||||
border: 1px solid #CCC;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 10px;
|
||||
background-color: white;
|
||||
font-weight: 500;
|
||||
color: #394d5c;
|
||||
}
|
||||
|
||||
.session-entry:hover {
|
||||
border-color: #00a6ff;
|
||||
}
|
||||
|
||||
.login-c2a-session-list, .signup-c2a-session-list {
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: #636363;
|
||||
}
|
||||
|
||||
.login-c2a-session-list:hover, .signup-c2a-session-list:hover {
|
||||
text-decoration: underline;
|
||||
;
|
||||
}
|
||||
|
||||
/*****************************************************
|
||||
* Taskbar
|
||||
*****************************************************/
|
||||
@@ -6139,13 +6113,6 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.device-phone .window.window-login-2fa {
|
||||
left: 50% !important;
|
||||
transform: translate(-50%) !important;
|
||||
height: initial !important;
|
||||
max-width: calc(100% - 30px);
|
||||
}
|
||||
|
||||
.device-phone .window.window-explorer {
|
||||
transform: none;
|
||||
width: 100%;
|
||||
@@ -6165,15 +6132,6 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.device-phone .window-signup {
|
||||
transform: none;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.device-phone .send-feedback-btn {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -6404,17 +6362,6 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.window-recover-password.window-cover-page .window-body{
|
||||
background-color: white !important;
|
||||
}
|
||||
.window-recover-password .window-body{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
/* -------------------------------------------------------------------------
|
||||
Permission Request Dialog (UIPermissionDialog)
|
||||
Standalone top-layer <dialog>; follows the OS light/dark scheme.
|
||||
|
||||
@@ -23,7 +23,7 @@ import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
|
||||
|
||||
+8
-10
@@ -36,7 +36,7 @@ import mime from './lib/mime.js';
|
||||
import path from './lib/path.js';
|
||||
import UIAlert from './UI/UIAlert.js';
|
||||
import UIItem from './UI/UIItem.js';
|
||||
import UIWindowLogin from './UI/UIWindowLogin.js';
|
||||
import UIAuthLogin from './UI/Auth/UIAuthLogin.js';
|
||||
import UIWindowProgress from './UI/UIWindowProgress.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
|
||||
@@ -67,7 +67,7 @@ window.is_auth = () => {
|
||||
* 1. Snapshot enough state to land back where we were after sign-in.
|
||||
* 2. Clear the token key (and the retired one, if a value lingers).
|
||||
* 3. Show a soft modal explaining what happened.
|
||||
* 4. Re-open UIWindowLogin, forwarding `auth_id` so temp users can
|
||||
* 4. Re-open the login dialog, forwarding `auth_id` so temp users can
|
||||
* re-attach to the same identity.
|
||||
*
|
||||
* Idempotent: parallel 401s while the modal is already open are dropped.
|
||||
@@ -106,12 +106,12 @@ window.handleReauthRequired = async (signal = {}) => {
|
||||
});
|
||||
} catch ( e ) {
|
||||
// If the alert surface isn't available (very early in boot),
|
||||
// fall through to UIWindowLogin directly.
|
||||
// fall through to the login dialog directly.
|
||||
}
|
||||
|
||||
// Open the login window, forwarding auth_id.
|
||||
try {
|
||||
const login = await UIWindowLogin({
|
||||
const login = await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
auth_id: signal.auth_id,
|
||||
reauth_reason: signal.reason,
|
||||
@@ -643,8 +643,11 @@ window.update_auth_data = async (auth_token, user) => {
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to sync session cookie:', e);
|
||||
// Sign-in waits on this with its card still up, above every
|
||||
// ordinary window.
|
||||
await UIAlert({
|
||||
message: `Failed to sync session cookie: ${ e.message}`,
|
||||
stay_on_top: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1077,15 +1080,10 @@ window.show_save_account_notice_if_needed = function (message) {
|
||||
});
|
||||
|
||||
} else if ( alert_resp === 'login' ) {
|
||||
let login_result = await UIWindowLogin({
|
||||
let login_result = await UIAuthLogin({
|
||||
show_signup_button: false,
|
||||
reload_on_success: true,
|
||||
send_confirmation_code: false,
|
||||
window_options: {
|
||||
show_in_taskbar: false,
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
});
|
||||
// FIXME: Report login error.
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
const UIAlert = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock('./UI/UIAlert.js', () => ({ default: UIAlert }));
|
||||
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./helpers/updateUsernameInGui.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = key => key;
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
globalThis.use = globalThis.use ?? (() => class {});
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.puter = {
|
||||
_cache: { flushall: vi.fn() },
|
||||
setAuthToken: vi.fn(),
|
||||
auth: { getProfile: vi.fn(async () => ({})) },
|
||||
};
|
||||
|
||||
await import('./helpers.js');
|
||||
|
||||
describe('update_auth_data', () => {
|
||||
beforeEach(() => {
|
||||
UIAlert.mockClear();
|
||||
localStorage.clear();
|
||||
window.user = { uuid: 'old', username: 'old' };
|
||||
window.logged_in_users = [];
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.available_templates = vi.fn();
|
||||
});
|
||||
|
||||
// Sign-in awaits this while its card is still up, over every ordinary window.
|
||||
it('raises a failed session-cookie sync above the sign-in card', async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
await window.update_auth_data('token', { uuid: 'new', username: 'new' });
|
||||
expect(UIAlert).toHaveBeenCalledOnce();
|
||||
expect(UIAlert.mock.calls[0][0]).toMatchObject({ stay_on_top: true });
|
||||
});
|
||||
});
|
||||
@@ -81,16 +81,18 @@ function openerLogoTile(origin, fallbackSrc) {
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.logoSrc - URL of the Puter logo.
|
||||
* @param {boolean} [options.logoClickable] - Adds the pointer cursor; the
|
||||
* caller wires the click.
|
||||
* @param {string} [options.logoHref] - Makes the logo a link there, named
|
||||
* for Puter; without it the logo is decorative.
|
||||
* @param {string} [options.openerOrigin] - When set, the opener's favicon is
|
||||
* rendered beside the Puter logo with a dashed connector between them.
|
||||
* @param {string} [options.openerFallbackSrc] - Generic website icon shown
|
||||
* when the favicon is missing.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function authLogoHeader({ logoSrc, logoClickable = false, openerOrigin = '', openerFallbackSrc = '' }) {
|
||||
const puterLogo = `<img src="${html_encode(logoSrc)}" class="auth-logo"${logoClickable ? ' style="cursor: pointer;"' : ''}>`;
|
||||
export function authLogoHeader({ logoSrc, logoHref = '', openerOrigin = '', openerFallbackSrc = '' }) {
|
||||
const puterLogo = logoHref
|
||||
? `<a class="auth-logo-link" href="${html_encode(logoHref)}"><img src="${html_encode(logoSrc)}" class="auth-logo" alt="Puter"></a>`
|
||||
: `<img src="${html_encode(logoSrc)}" class="auth-logo" alt="">`;
|
||||
|
||||
let h = '';
|
||||
h += `<div class="auth-logo-header${openerOrigin ? ' auth-logo-header-connected' : ''}">`;
|
||||
|
||||
@@ -51,11 +51,15 @@ describe('authLogoHeader', () => {
|
||||
expect(h).toContain('class="auth-logo"');
|
||||
expect(h).not.toContain('auth-opener-logo');
|
||||
expect(h).not.toContain('auth-logo-connector');
|
||||
expect(h).not.toContain('cursor: pointer');
|
||||
expect(h).not.toContain('<a ');
|
||||
// Decorative: the title beside it names the screen.
|
||||
expect(h).toContain('alt=""');
|
||||
});
|
||||
|
||||
it('adds the pointer cursor when the logo is clickable', () => {
|
||||
expect(authLogoHeader({ logoSrc: LOGO, logoClickable: true })).toContain('cursor: pointer');
|
||||
it('makes the logo a named link when it leads somewhere', () => {
|
||||
const h = authLogoHeader({ logoSrc: LOGO, logoHref: '/desktop' });
|
||||
expect(h).toContain('<a class="auth-logo-link" href="/desktop">');
|
||||
expect(h).toContain('alt="Puter"');
|
||||
});
|
||||
|
||||
it('pairs the opener favicon with the Puter logo through a connector', () => {
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('the permission popup and browser push', () => {
|
||||
});
|
||||
|
||||
describe('the account pickers in a popup', () => {
|
||||
const PICKER = 'picked_a_user_for_sdk_login = await UIWindowSessionList(';
|
||||
const PICKER = 'picked_a_user_for_sdk_login = await UIAuthAccountPicker(';
|
||||
|
||||
it('record that they were shown', () => {
|
||||
// A dismissed picker has to outrank an existing relationship, so the
|
||||
|
||||
+26
-52
@@ -33,17 +33,17 @@ import UIWindowPasswordChangeRequired from './UI/UIWindowPasswordChangeRequired.
|
||||
import UIWindowPhoneVerificationRequired from './UI/UIWindowPhoneVerificationRequired.js';
|
||||
import UIWindowCardVerificationRequired from './UI/UIWindowCardVerificationRequired.js';
|
||||
import { openVerificationGateWindow } from './helpers/verification_gates.js';
|
||||
import UIWindowLogin from './UI/UIWindowLogin.js';
|
||||
import UIWindowLogin2FA from './UI/UIWindowLogin2FA.js';
|
||||
import UIAuthLogin from './UI/Auth/UIAuthLogin.js';
|
||||
import UIAuth2FA from './UI/Auth/UIAuth2FA.js';
|
||||
import { takeOidcLoginChallenge } from './helpers/authRedirect.js';
|
||||
import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js';
|
||||
import UIWindowNewPassword from './UI/UIWindowNewPassword.js';
|
||||
import UIPermissionDialog from './UI/UIPermissionDialog.js';
|
||||
import UIEnablePushDialog from './UI/UIEnablePushDialog.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
import UIWindowSessionList from './UI/UIWindowSessionList.js';
|
||||
import UIWindowSignup from './UI/UIWindowSignup.js';
|
||||
import UIWindowRecoverPassword from './UI/UIWindowRecoverPassword.js';
|
||||
import UIAuthAccountPicker from './UI/Auth/UIAuthAccountPicker.js';
|
||||
import UIAuthSignup from './UI/Auth/UIAuthSignup.js';
|
||||
import UIAuthRecoverPassword from './UI/Auth/UIAuthRecoverPassword.js';
|
||||
import { PROCESS_RUNNING } from './definitions.js';
|
||||
import { isAttestedOrigin } from './helpers/attestedOrigin.js';
|
||||
import confirm_before_unload from './helpers/confirmBeforeUnload.js';
|
||||
@@ -140,7 +140,7 @@ const follow_share_recipient = async (action) => {
|
||||
});
|
||||
if ( choice !== 'sign_in' ) return false;
|
||||
// Reloads on success; closing it carries on as the current account.
|
||||
return Boolean(await UIWindowLogin({
|
||||
return Boolean(await UIAuthLogin({
|
||||
reload_on_success: true,
|
||||
send_confirmation_code: true,
|
||||
}));
|
||||
@@ -1521,7 +1521,7 @@ window.initgui = async function (options) {
|
||||
window.gui_action = action;
|
||||
|
||||
if (action === 'oidc-2fa') {
|
||||
const result = oidcChallenge?.token && await UIWindowLogin2FA(oidcChallenge.token);
|
||||
const result = oidcChallenge?.token && await UIAuth2FA(oidcChallenge.token);
|
||||
if (result?.redirectUrl) {
|
||||
const target = new URL(result.redirectUrl, window.location.origin);
|
||||
if (target.origin === window.location.origin) {
|
||||
@@ -1736,14 +1736,10 @@ window.initgui = async function (options) {
|
||||
) {
|
||||
// show signup window
|
||||
if (
|
||||
await UIWindowSignup({
|
||||
await UIAuthSignup({
|
||||
reload_on_success: false,
|
||||
send_confirmation_code: true,
|
||||
show_close_button: false,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
},
|
||||
page: true,
|
||||
})
|
||||
) {
|
||||
// Completing signup in a sign-in popup is the user asking to
|
||||
@@ -1802,11 +1798,9 @@ window.initgui = async function (options) {
|
||||
} else {
|
||||
// Show session list so user can pick which account to use
|
||||
showed_account_picker = true;
|
||||
picked_a_user_for_sdk_login = await UIWindowSessionList({
|
||||
picked_a_user_for_sdk_login = await UIAuthAccountPicker({
|
||||
reload_on_success: false,
|
||||
draggable_body: false,
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
page: true,
|
||||
});
|
||||
|
||||
if (picked_a_user_for_sdk_login) {
|
||||
@@ -1912,20 +1906,15 @@ window.initgui = async function (options) {
|
||||
? { authError: authErrorDisplayMessage() }
|
||||
: {};
|
||||
if (!window.is_auth()) {
|
||||
opts.window_options = { cover_page: true, has_head: false };
|
||||
opts.page = true;
|
||||
}
|
||||
await UIWindowLogin(Object.keys(opts).length ? opts : undefined);
|
||||
await UIAuthLogin(opts);
|
||||
}
|
||||
//--------------------------------------------------------------------------------------
|
||||
// Action: Password recovery
|
||||
//--------------------------------------------------------------------------------------
|
||||
else if (action === 'password-recovery') {
|
||||
await UIWindowRecoverPassword({
|
||||
window_options: {
|
||||
cover_page: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
await UIAuthRecoverPassword({ page: true });
|
||||
}
|
||||
//--------------------------------------------------------------------------------------
|
||||
// Action: Signup
|
||||
@@ -1935,10 +1924,10 @@ window.initgui = async function (options) {
|
||||
? { authError: authErrorDisplayMessage() }
|
||||
: {};
|
||||
if (!window.is_auth()) {
|
||||
opts.window_options = { cover_page: true, has_head: false };
|
||||
opts.page = true;
|
||||
}
|
||||
opts.send_confirmation_code = true;
|
||||
await UIWindowSignup(Object.keys(opts).length ? opts : undefined);
|
||||
await UIAuthSignup(opts);
|
||||
}
|
||||
// Which URL parameter (if any) is carrying a token to sign in with —
|
||||
// `auth_token`, or `token` when returning from a remote backend's AuthMe.
|
||||
@@ -1965,11 +1954,9 @@ window.initgui = async function (options) {
|
||||
window.url_query_params.get('request_auth'))
|
||||
) {
|
||||
showed_account_picker = true;
|
||||
picked_a_user_for_sdk_login = await UIWindowSessionList({
|
||||
picked_a_user_for_sdk_login = await UIAuthAccountPicker({
|
||||
reload_on_success: false,
|
||||
draggable_body: false,
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
page: true,
|
||||
});
|
||||
if (picked_a_user_for_sdk_login) {
|
||||
window.popup_signin_consent = true;
|
||||
@@ -2509,26 +2496,24 @@ window.initgui = async function (options) {
|
||||
const needs_action = action === 'authme' || action === 'copyauth';
|
||||
const reload_on_success = needs_action;
|
||||
if (window.logged_in_users.length > 0) {
|
||||
// dashboard mode skips the wallpaper (fullpage), but the session list
|
||||
// has no cover page — restore the wallpaper so it isn't on a blank page
|
||||
// dashboard mode skips the wallpaper (fullpage), but the account
|
||||
// picker is a dialog, not a page — restore the wallpaper so it
|
||||
// isn't on a blank page
|
||||
if (window.is_dashboard_mode) {
|
||||
window.refresh_desktop_background();
|
||||
}
|
||||
await UIWindowSessionList({
|
||||
await UIAuthAccountPicker({
|
||||
redirect_url: needs_action ? window.location.href : undefined,
|
||||
});
|
||||
} else {
|
||||
const resp = await fetch(`${window.gui_origin}/whoarewe`);
|
||||
const whoarewe = await resp.json();
|
||||
await UIWindowLogin({
|
||||
await UIAuthLogin({
|
||||
reload_on_success: !window.embedded_in_popup,
|
||||
send_confirmation_code: true,
|
||||
show_signup_button: !whoarewe.disable_user_signup,
|
||||
redirect_url: needs_action ? window.location.href : undefined,
|
||||
window_options: {
|
||||
cover_page: true,
|
||||
has_head: false,
|
||||
},
|
||||
page: true,
|
||||
});
|
||||
}
|
||||
if (!reload_on_success && window.is_auth()) {
|
||||
@@ -2717,13 +2702,10 @@ window.initgui = async function (options) {
|
||||
// hide Turnstile challenge
|
||||
$('.captcha-modal').hide();
|
||||
|
||||
await UIWindowSignup({
|
||||
await UIAuthSignup({
|
||||
reload_on_success: !window.embedded_in_popup,
|
||||
send_confirmation_code: true,
|
||||
window_options: {
|
||||
has_head: false,
|
||||
cover_page: true,
|
||||
},
|
||||
page: true,
|
||||
});
|
||||
|
||||
// Popup-only: it posts to `window.opener` and closes the
|
||||
@@ -3088,14 +3070,6 @@ $(window).on('resize', function () {
|
||||
left: (window.desktop_width - width) / 2,
|
||||
top: top / ratio,
|
||||
});
|
||||
|
||||
// Re-center the create account window
|
||||
const top2 = $('.window-signup').position()?.top;
|
||||
const width2 = $('.window-signup').width();
|
||||
$('.window-signup').css({
|
||||
left: (window.desktop_width - width2) / 2,
|
||||
top: top2 / ratio,
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('contextmenu', '.disable-context-menu', function (e) {
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
);
|
||||
|
||||
// The user is asked, rather than the popup deciding for them.
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
@@ -106,7 +106,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
'&opener_origin=https%3A%2F%2Fconsole.puter.com',
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.waitForTimeout(5000);
|
||||
@@ -129,7 +129,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
`&signin_session=${session}&msg_id=90`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
@@ -149,7 +149,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
`&signin_session=${session}&msg_id=92`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
|
||||
@@ -170,7 +170,7 @@ test.describe('popup sign-in cannot be driven from a link', () => {
|
||||
`&signin_session=${session}&msg_id=91`,
|
||||
);
|
||||
|
||||
await expect(popup.locator('.window-session-list')).toBeVisible({
|
||||
await expect(popup.locator('.auth-dialog [data-screen="accounts"]')).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await popup.close();
|
||||
|
||||
@@ -320,7 +320,7 @@ export const transformToV2 = (source) => {
|
||||
if ( svc['onlyoffice-app'] ) out.onlyoffice = svc['onlyoffice-app'];
|
||||
|
||||
// Cloudflare Turnstile: v2 GUI renders the challenge widget when
|
||||
// `gui_params.turnstileSiteKey` is set (see initgui.js / UIWindowSignup).
|
||||
// `gui_params.turnstileSiteKey` is set (see initgui.js / UI/Auth/UIAuthSignup.js).
|
||||
// Preserve the full block at `turnstile` for whenever the backend verifier
|
||||
// is ported, and surface the site key into gui_params so the widget works.
|
||||
if ( svc['cloudflare-turnstile'] ) {
|
||||
|
||||
Reference in new issue
Block a user