mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bcfd0c620 | ||
|
|
df2c5978b1 | ||
|
|
831fd421a0 | ||
|
|
b33286b106 | ||
|
|
8e7c64ad55 | ||
|
|
bc59376884 | ||
|
|
c327ff6d69 | ||
|
|
992e24b943 | ||
|
|
987521ffd4 | ||
|
|
06401516bf | ||
|
|
15cbb041dc | ||
|
|
337006d99c |
No files matched your search
@@ -17,13 +17,15 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIWindowChangePassword from '../UIWindowChangePassword.js';
|
||||
import UIWindowChangeEmail from '../Settings/UIWindowChangeEmail.js';
|
||||
import UIWindowChangeUsername from '../UIWindowChangeUsername.js';
|
||||
import UIWindowConfirmUserDeletion from '../Settings/UIWindowConfirmUserDeletion.js';
|
||||
import UIWindowCopyToken from '../UIWindowCopyToken.js';
|
||||
import UIWindowEditProfile from '../UIWindowEditProfile.js';
|
||||
import UIWindow from '../UIWindow.js';
|
||||
import {
|
||||
openChangeEmailDialog,
|
||||
openChangePasswordDialog,
|
||||
openChangeUsernameDialog,
|
||||
openCreateTokenDialog,
|
||||
openDeleteAccountDialog,
|
||||
openEditProfileDialog,
|
||||
} from './UIAccountDialogs.js';
|
||||
import UIProfilePictureCropModal from './UIProfilePictureCropModal.js';
|
||||
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
|
||||
|
||||
@@ -178,85 +180,31 @@ const TabAccount = {
|
||||
},
|
||||
|
||||
init ($el_window) {
|
||||
$el_window.find('.dashboard-section-account .edit-profile').on('click', function (e) {
|
||||
UIWindowEditProfile({
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
// Each dialog mounts over the dashboard and hands focus back to its button.
|
||||
const dialogOptions = (button) => ({ $container: $el_window, returnFocusTo: button });
|
||||
$el_window.find('.dashboard-section-account .edit-profile').on('click', function () {
|
||||
openEditProfileDialog({
|
||||
...dialogOptions(this),
|
||||
onSave: (profile) => {
|
||||
$el_window.find('.dashboard-section-account .dashboard-profile-display-name')
|
||||
.text(profile.displayName || i18n('profile_empty', [], false));
|
||||
},
|
||||
});
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .change-password').on('click', function (e) {
|
||||
UIWindowChangePassword({
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .change-password').on('click', function () {
|
||||
openChangePasswordDialog(dialogOptions(this));
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .change-username').on('click', function (e) {
|
||||
UIWindowChangeUsername({
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .change-username').on('click', function () {
|
||||
openChangeUsernameDialog(dialogOptions(this));
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .change-email').on('click', function (e) {
|
||||
UIWindowChangeEmail({
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .change-email').on('click', function () {
|
||||
openChangeEmailDialog(dialogOptions(this));
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .copy-auth-token').on('click', function (e) {
|
||||
UIWindowCopyToken({
|
||||
show_close_button: true,
|
||||
show_header: true,
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
is_draggable: false,
|
||||
draggable_body: false,
|
||||
},
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .copy-auth-token').on('click', function () {
|
||||
openCreateTokenDialog({ ...dialogOptions(this), $sessionsParent: $el_window });
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .delete-account').on('click', function (e) {
|
||||
UIWindowConfirmUserDeletion({
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .delete-account').on('click', function () {
|
||||
openDeleteAccountDialog(dialogOptions(this));
|
||||
});
|
||||
$el_window.find('.dashboard-section-account .change-profile-picture').on('keydown', function (e) {
|
||||
if ( e.key !== 'Enter' && e.key !== ' ' ) return;
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
|
||||
import UIWindowDisable2FA from '../Settings/UIWindowDisable2FA.js';
|
||||
import UIWindow2FASetup from '../UIWindow2FASetup.js';
|
||||
import UIWindowChangePassword from '../UIWindowChangePassword.js';
|
||||
import UIWindowBlockedSenders from '../UIWindowBlockedSenders.js';
|
||||
import UIWindowManageSessions from '../UIWindowManageSessions.js';
|
||||
import { isOrgSeat } from './orgSeat.js';
|
||||
import { openChangePasswordDialog } from './UIAccountDialogs.js';
|
||||
|
||||
const TabSecurity = {
|
||||
id: 'security',
|
||||
@@ -115,17 +115,8 @@ const TabSecurity = {
|
||||
},
|
||||
|
||||
init ($el_window) {
|
||||
$el_window.find('.dashboard-section-security .change-password').on('click', function (e) {
|
||||
UIWindowChangePassword({
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
$el_window.find('.dashboard-section-security .change-password').on('click', function () {
|
||||
openChangePasswordDialog({ $container: $el_window, returnFocusTo: this });
|
||||
});
|
||||
|
||||
$el_window.find('.dashboard-section-security .manage-sessions').on('click', function (e) {
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
* 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 (and Security) tab's dialogs: dashboard form dialogs rather
|
||||
// than desktop windows, so they read as part of the dashboard and dock as
|
||||
// bottom sheets on phones.
|
||||
|
||||
import check_password_strength from '../../helpers/checkPasswordStrength.js';
|
||||
import create_access_token from '../../helpers/createAccessToken.js';
|
||||
import postUserProtected from '../../helpers/postUserProtected.js';
|
||||
import update_username_in_gui from '../../helpers/updateUsernameInGui.js';
|
||||
import UIWindowManageSessions from '../UIWindowManageSessions.js';
|
||||
import UIDashboardFormDialog from './UIDashboardFormDialog.js';
|
||||
|
||||
// Keep in sync with ProfileService on the backend.
|
||||
const DISPLAY_NAME_MAX_LENGTH = 64;
|
||||
const BIO_MAX_LENGTH = 280;
|
||||
const TOKEN_LABEL_MAX_LENGTH = 64;
|
||||
const TOKEN_COPIED_RESET_MS = 2000;
|
||||
|
||||
/**
|
||||
* @typedef {object} AccountDialogOptions
|
||||
* @property {jQuery} [$container] Where the overlay mounts.
|
||||
* @property {HTMLElement} [returnFocusTo] Where focus goes on close.
|
||||
*/
|
||||
|
||||
const t = (key) => i18n(key, [], false);
|
||||
|
||||
/** An account that signs in only through a linked provider has no password to ask for. */
|
||||
const isOidcOnly = () => !! window.user?.oidc_only;
|
||||
|
||||
/** The account-password field the protected routes need, unless the account has none. */
|
||||
const passwordField = (label) => ({
|
||||
name: 'password',
|
||||
type: 'password',
|
||||
label,
|
||||
hidden: isOidcOnly(),
|
||||
attrs: { autocomplete: 'current-password' },
|
||||
});
|
||||
|
||||
/** Text that tells a provider-only account what will happen instead of a password. */
|
||||
const oidcNotice = () => (isOidcOnly() ? `<p>${i18n('revalidate_flow_notice')}</p>` : '');
|
||||
|
||||
/** Status while the provider's sign-in popup is up. */
|
||||
const revalidating = (dialog) => () => dialog.setStatus(t('revalidate_sign_in_popup'), { kind: 'info' });
|
||||
|
||||
/**
|
||||
* Edits the signed-in user's display name and bio.
|
||||
*
|
||||
* @param {AccountDialogOptions & { onSave?: (profile: { displayName?: string|null, bio?: string|null }) => void }} [opts]
|
||||
*/
|
||||
export function openEditProfileDialog ({ onSave, ...opts } = {}) {
|
||||
return UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t('edit_profile'),
|
||||
submitLabel: t('save'),
|
||||
fields: [
|
||||
{
|
||||
name: 'displayName',
|
||||
label: i18n('display_name'),
|
||||
value: window.user?.profile?.displayName ?? '',
|
||||
attrs: { maxlength: DISPLAY_NAME_MAX_LENGTH, autocomplete: 'off' },
|
||||
},
|
||||
{
|
||||
name: 'bio',
|
||||
type: 'textarea',
|
||||
label: i18n('bio'),
|
||||
value: window.user?.profile?.bio ?? '',
|
||||
attrs: { maxlength: BIO_MAX_LENGTH, rows: 4 },
|
||||
},
|
||||
],
|
||||
async onSubmit (dialog) {
|
||||
const values = dialog.values();
|
||||
// An empty field clears it.
|
||||
const patch = {
|
||||
displayName: values.displayName.trim() || null,
|
||||
bio: values.bio.trim() || null,
|
||||
};
|
||||
// update_profile resolves with no profile when the request failed.
|
||||
const profile = await window.update_profile(window.user.username, patch);
|
||||
if ( ! profile ) {
|
||||
dialog.setStatus(t('profile_save_failed'));
|
||||
return;
|
||||
}
|
||||
onSave?.(profile);
|
||||
dialog.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the signed-in user's username.
|
||||
*
|
||||
* @param {AccountDialogOptions} [opts]
|
||||
*/
|
||||
export function openChangeUsernameDialog (opts = {}) {
|
||||
return UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t('change_username'),
|
||||
intro: oidcNotice(),
|
||||
submitLabel: t('change_username'),
|
||||
fields: [
|
||||
{
|
||||
name: 'username',
|
||||
label: i18n('new_username'),
|
||||
attrs: { autocomplete: 'off', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false' },
|
||||
},
|
||||
passwordField(i18n('account_password')),
|
||||
],
|
||||
async onSubmit (dialog) {
|
||||
const { username, password } = dialog.values();
|
||||
const new_username = username.trim();
|
||||
if ( ! new_username ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'username' });
|
||||
return;
|
||||
}
|
||||
if ( ! isOidcOnly() && ! password ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'password' });
|
||||
return;
|
||||
}
|
||||
const result = await postUserProtected('change-username', { new_username }, {
|
||||
password,
|
||||
onRevalidate: revalidating(dialog),
|
||||
});
|
||||
if ( ! result.ok ) {
|
||||
dialog.setStatus(result.message);
|
||||
return;
|
||||
}
|
||||
update_username_in_gui(new_username);
|
||||
window.user.username = new_username;
|
||||
dialog.finish({ message: t('username_changed') });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the signed-in user's password.
|
||||
*
|
||||
* @param {AccountDialogOptions} [opts]
|
||||
*/
|
||||
export function openChangePasswordDialog (opts = {}) {
|
||||
return UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t('window_title_change_password'),
|
||||
intro: oidcNotice(),
|
||||
submitLabel: t('change_password'),
|
||||
fields: [
|
||||
{
|
||||
...passwordField(i18n('current_password')),
|
||||
name: 'current',
|
||||
},
|
||||
{
|
||||
name: 'next',
|
||||
type: 'password',
|
||||
label: i18n('new_password'),
|
||||
hint: i18n('password_strength_error'),
|
||||
attrs: { autocomplete: 'new-password' },
|
||||
},
|
||||
{
|
||||
name: 'confirm',
|
||||
type: 'password',
|
||||
label: i18n('confirm_new_password'),
|
||||
attrs: { autocomplete: 'new-password' },
|
||||
},
|
||||
],
|
||||
async onSubmit (dialog) {
|
||||
const { current, next, confirm } = dialog.values();
|
||||
if ( ! isOidcOnly() && ! current ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'current' });
|
||||
return;
|
||||
}
|
||||
if ( ! next || ! confirm ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: next ? 'confirm' : 'next' });
|
||||
return;
|
||||
}
|
||||
if ( next !== confirm ) {
|
||||
dialog.setStatus(t('passwords_do_not_match'), { field: 'confirm' });
|
||||
return;
|
||||
}
|
||||
if ( ! check_password_strength(next).overallPass ) {
|
||||
dialog.setStatus(t('password_strength_error'), { field: 'next' });
|
||||
return;
|
||||
}
|
||||
const result = await postUserProtected('change-password', { new_pass: next }, {
|
||||
password: current,
|
||||
onRevalidate: revalidating(dialog),
|
||||
});
|
||||
if ( ! result.ok ) {
|
||||
dialog.setStatus(result.message);
|
||||
return;
|
||||
}
|
||||
dialog.finish({ message: t('password_changed') });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts changing the signed-in user's email: the server mails a link to
|
||||
* the new address, and the change lands when it is followed.
|
||||
*
|
||||
* @param {AccountDialogOptions} [opts]
|
||||
*/
|
||||
export function openChangeEmailDialog (opts = {}) {
|
||||
const dialog = UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t('change_email'),
|
||||
intro: oidcNotice(),
|
||||
submitLabel: t('change_email'),
|
||||
fields: [
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: i18n('new_email'),
|
||||
attrs: { autocomplete: 'email', inputmode: 'email', autocapitalize: 'off', spellcheck: 'false' },
|
||||
},
|
||||
passwordField(i18n('account_password')),
|
||||
{
|
||||
name: 'code',
|
||||
label: i18n('confirm_code_2fa_instruction'),
|
||||
hidden: ! window.user?.otp,
|
||||
attrs: { inputmode: 'numeric', autocomplete: 'one-time-code', maxlength: 6, pattern: '[0-9]*' },
|
||||
},
|
||||
],
|
||||
async onSubmit () {
|
||||
const { email, password, code } = dialog.values();
|
||||
const new_email = email.trim();
|
||||
if ( ! new_email ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'email' });
|
||||
return;
|
||||
}
|
||||
if ( ! isOidcOnly() && ! password ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'password' });
|
||||
return;
|
||||
}
|
||||
// Kept as typed: an authenticator code can start with zeroes.
|
||||
const result = await postUserProtected('change-email', { new_email, code: code || undefined }, {
|
||||
password,
|
||||
onRevalidate: revalidating(dialog),
|
||||
});
|
||||
if ( ! result.ok ) {
|
||||
// Accounts with 2FA turned on since the page loaded learn about it here.
|
||||
const needs_code = result.data.factor === 'otp';
|
||||
if ( needs_code ) dialog.showField('code', true);
|
||||
dialog.setStatus(result.message, needs_code ? { field: 'code' } : undefined);
|
||||
return;
|
||||
}
|
||||
// The address is the account's once the emailed link is followed, not yet.
|
||||
dialog.finish({ message: t('email_change_confirmation_sent') });
|
||||
},
|
||||
});
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a named, revocable API token and shows it once.
|
||||
*
|
||||
* @param {AccountDialogOptions & { $sessionsParent?: jQuery }} [opts]
|
||||
* `$sessionsParent` is the window the session manager link opens over.
|
||||
*/
|
||||
export function openCreateTokenDialog ({ $sessionsParent, ...opts } = {}) {
|
||||
const dialog = UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t('create_api_token'),
|
||||
intro: `<p>${i18n('create_token_message')}</p>`,
|
||||
submitLabel: t('create_token'),
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
label: i18n('token_label'),
|
||||
placeholder: i18n('token_label_placeholder'),
|
||||
attrs: { maxlength: TOKEN_LABEL_MAX_LENGTH, autocomplete: 'off' },
|
||||
},
|
||||
{
|
||||
name: 'expiresIn',
|
||||
type: 'select',
|
||||
label: i18n('token_expiry'),
|
||||
value: '',
|
||||
options: [
|
||||
{ value: '', label: i18n('token_expiry_never') },
|
||||
{ value: '7d', label: i18n('token_expiry_7d') },
|
||||
{ value: '30d', label: i18n('token_expiry_30d') },
|
||||
{ value: '90d', label: i18n('token_expiry_90d') },
|
||||
],
|
||||
},
|
||||
],
|
||||
async onSubmit () {
|
||||
const { label, expiresIn } = dialog.values();
|
||||
if ( ! label.trim() ) {
|
||||
dialog.setStatus(t('token_label_required'), { field: 'label' });
|
||||
return;
|
||||
}
|
||||
const token = await create_access_token({ label: label.trim(), expiresIn: expiresIn || null });
|
||||
|
||||
let h = `<div class="dashboard-token-warning">${i18n('token_shown_once_warning')}</div>`;
|
||||
h += '<div class="dashboard-token-row">';
|
||||
h += `<input type="text" class="dashboard-dialog-input dashboard-token-value" readonly value="${html_encode(token)}" aria-label="${i18n('api_token')}" spellcheck="false">`;
|
||||
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-token-copy">${i18n('copy')}</button>`;
|
||||
h += '</div>';
|
||||
h += `<p class="dashboard-form-hint">${i18n('token_manage_hint')} <button type="button" class="dashboard-token-sessions">${i18n('ui_manage_sessions')}</button>.</p>`;
|
||||
dialog.finish({ html: h });
|
||||
},
|
||||
});
|
||||
|
||||
let copied_timer = null;
|
||||
dialog.$dialog.on('click', '.dashboard-token-copy', function () {
|
||||
const $btn = $(this);
|
||||
const $value = dialog.$dialog.find('.dashboard-token-value');
|
||||
navigator.clipboard.writeText(String($value.val())).then(() => {
|
||||
$btn.text(t('token_copied'));
|
||||
clearTimeout(copied_timer);
|
||||
copied_timer = setTimeout(() => $btn.text(t('copy')), TOKEN_COPIED_RESET_MS);
|
||||
}, () => {
|
||||
// No clipboard access: select it so the user can copy it by hand.
|
||||
$value.get(0)?.select();
|
||||
});
|
||||
});
|
||||
dialog.$dialog.on('focus', '.dashboard-token-value', function () {
|
||||
this.select();
|
||||
});
|
||||
dialog.$dialog.on('click', '.dashboard-token-sessions', () => {
|
||||
UIWindowManageSessions({
|
||||
window_options: {
|
||||
parent_uuid: $sessionsParent?.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the signed-in user's account once they confirm it: by typing
|
||||
* "confirm" for a temporary account, with their password, or by signing in
|
||||
* with their linked provider when the account has no password.
|
||||
*
|
||||
* @param {AccountDialogOptions} [opts]
|
||||
*/
|
||||
export function openDeleteAccountDialog (opts = {}) {
|
||||
const is_temp = !! window.user?.is_temp;
|
||||
const fields = [];
|
||||
if ( is_temp ) {
|
||||
fields.push({
|
||||
name: 'confirm',
|
||||
label: i18n('type_confirm_to_delete_account'),
|
||||
attrs: { autocomplete: 'off', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false' },
|
||||
});
|
||||
} else if ( ! isOidcOnly() ) {
|
||||
fields.push(passwordField(i18n('enter_password_to_confirm_delete_user')));
|
||||
}
|
||||
|
||||
return UIDashboardFormDialog({
|
||||
...opts,
|
||||
tone: 'danger',
|
||||
title: t('confirm_delete_user_title'),
|
||||
intro: `<p>${i18n('confirm_delete_user')}</p>${is_temp ? '' : oidcNotice()}`,
|
||||
submitLabel: t('delete_account'),
|
||||
fields,
|
||||
async onSubmit (dialog) {
|
||||
const { confirm, password } = dialog.values();
|
||||
if ( is_temp ) {
|
||||
// "confirm", or the word in the user's language.
|
||||
const typed = confirm.trim().toLowerCase();
|
||||
if ( typed !== 'confirm' && typed !== t('confirm').toLowerCase() ) {
|
||||
dialog.setStatus(t('type_confirm_to_delete_account'), { field: 'confirm' });
|
||||
return;
|
||||
}
|
||||
} else if ( ! isOidcOnly() && ! password ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'password' });
|
||||
return;
|
||||
}
|
||||
const result = await postUserProtected('delete-own-user', {}, {
|
||||
password: is_temp ? undefined : password,
|
||||
onRevalidate: revalidating(dialog),
|
||||
});
|
||||
if ( ! result.ok || ! result.data.success ) {
|
||||
dialog.setStatus(result.message ?? t('error_unknown_cause'));
|
||||
return;
|
||||
}
|
||||
window.user.deleted = true;
|
||||
window.logout();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../../util/openid.js', () => ({ openRevalidatePopup: vi.fn(async () => {}) }));
|
||||
vi.mock('../UIWindowManageSessions.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => 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 });
|
||||
|
||||
const dialogs = await import('./UIAccountDialogs.js');
|
||||
const { openRevalidatePopup } = await import('../../util/openid.js');
|
||||
|
||||
const reply = (status, data = {}) => new Response(JSON.stringify(data), { status });
|
||||
const bodyOf = (index) => JSON.parse(fetch.mock.calls[index][1].body);
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const field = (name) => $(`.dashboard-form-input[name="${name}"]`);
|
||||
const status = () => $('.dashboard-form-status').text();
|
||||
const isOpen = () => $('.dashboard-dialog-overlay').hasClass('dashboard-dialog-show');
|
||||
|
||||
/** Fills the named fields, submits, and waits for the handler to finish. */
|
||||
const submitWith = async (values = {}) => {
|
||||
for ( const [name, value] of Object.entries(values) ) field(name).val(value);
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
// The handler awaits a fetch, a JSON parse and maybe a popup.
|
||||
for ( let i = 0; i < 10; i++ ) await settle();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.fetch = vi.fn();
|
||||
globalThis.puter = { _cache: { flushall: vi.fn() } };
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'tok-1';
|
||||
window.handleReauthRequired = vi.fn();
|
||||
window.logout = vi.fn();
|
||||
window.user = { username: 'alice', email: '[email protected]', profile: { displayName: 'Alice', bio: 'Hi there' } };
|
||||
openRevalidatePopup.mockClear();
|
||||
});
|
||||
|
||||
describe('edit profile', () => {
|
||||
it('prefills the current display name and bio', () => {
|
||||
dialogs.openEditProfileDialog();
|
||||
expect(field('displayName').val()).toBe('Alice');
|
||||
expect(field('bio').val()).toBe('Hi there');
|
||||
});
|
||||
|
||||
it('saves trimmed values, clears empty ones, and closes', async () => {
|
||||
window.update_profile = vi.fn(async (_username, patch) => patch);
|
||||
const onSave = vi.fn();
|
||||
dialogs.openEditProfileDialog({ onSave });
|
||||
|
||||
await submitWith({ displayName: ' Alice Smith ', bio: ' ' });
|
||||
|
||||
expect(window.update_profile).toHaveBeenCalledWith('alice', { displayName: 'Alice Smith', bio: null });
|
||||
expect(onSave).toHaveBeenCalledWith({ displayName: 'Alice Smith', bio: null });
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays open with an error when the save fails', async () => {
|
||||
window.update_profile = vi.fn(async () => undefined);
|
||||
const onSave = vi.fn();
|
||||
dialogs.openEditProfileDialog({ onSave });
|
||||
await settle();
|
||||
|
||||
await submitWith();
|
||||
|
||||
expect(status()).toBe('profile_save_failed');
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('change username', () => {
|
||||
it('asks for the password before posting', async () => {
|
||||
dialogs.openChangeUsernameDialog();
|
||||
await submitWith({ username: 'bob' });
|
||||
expect(status()).toBe('all_fields_required');
|
||||
expect(field('password').attr('aria-invalid')).toBe('true');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renames the user everywhere on success', async () => {
|
||||
document.body.innerHTML = '<span class="username">alice</span>';
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
dialogs.openChangeUsernameDialog();
|
||||
|
||||
await submitWith({ username: ' bob ', password: 'pw' });
|
||||
|
||||
expect(bodyOf(0)).toEqual({ new_username: 'bob', password: 'pw' });
|
||||
expect(window.user.username).toBe('bob');
|
||||
expect($('span.username').text()).toBe('bob');
|
||||
expect($('.dashboard-form-done p').text()).toBe('username_changed');
|
||||
});
|
||||
|
||||
it('hides the password field from a provider-only account and signs it in instead', async () => {
|
||||
window.user.oidc_only = true;
|
||||
window.user.oidc_revalidate_url = 'https://puter.test/revalidate';
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
dialogs.openChangeUsernameDialog();
|
||||
expect($('.dashboard-form-field[data-field="password"]').prop('hidden')).toBe(true);
|
||||
expect($('.dashboard-dialog-message').text()).toBe('revalidate_flow_notice');
|
||||
|
||||
await submitWith({ username: 'bob' });
|
||||
|
||||
expect(openRevalidatePopup).toHaveBeenCalledOnce();
|
||||
expect(bodyOf(0)).toEqual({ new_username: 'bob' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('change password', () => {
|
||||
const open = () => dialogs.openChangePasswordDialog();
|
||||
|
||||
it('checks the new password before posting', async () => {
|
||||
open();
|
||||
await submitWith({ current: 'Old1!pass', next: 'Strong1!pass', confirm: 'Strong1!pas' });
|
||||
expect(status()).toBe('passwords_do_not_match');
|
||||
expect(field('confirm').attr('aria-invalid')).toBe('true');
|
||||
|
||||
await submitWith({ next: 'weak', confirm: 'weak' });
|
||||
expect(status()).toBe('password_strength_error');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts the current and new password and confirms the change', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
open();
|
||||
|
||||
await submitWith({ current: 'Old1!pass', next: 'Strong1!pass', confirm: 'Strong1!pass' });
|
||||
|
||||
expect(bodyOf(0)).toEqual({ new_pass: 'Strong1!pass', password: 'Old1!pass' });
|
||||
expect($('.dashboard-form-done p').text()).toBe('password_changed');
|
||||
});
|
||||
|
||||
it('shows the server message and keeps the form when the change is refused', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(400, { message: 'Wrong password' }));
|
||||
open();
|
||||
|
||||
await submitWith({ current: 'Old1!pass', next: 'Strong1!pass', confirm: 'Strong1!pass' });
|
||||
|
||||
expect(status()).toBe('Wrong password');
|
||||
expect(field('next').prop('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('change email', () => {
|
||||
it('shows the code field for an account with 2FA and keeps leading zeroes', async () => {
|
||||
window.user.otp = true;
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
dialogs.openChangeEmailDialog();
|
||||
expect($('.dashboard-form-field[data-field="code"]').prop('hidden')).toBe(false);
|
||||
|
||||
await submitWith({ email: '[email protected]', password: 'pw', code: '000123' });
|
||||
|
||||
expect(bodyOf(0)).toEqual({ new_email: '[email protected]', password: 'pw', code: '000123' });
|
||||
expect($('.dashboard-form-done p').text()).toBe('email_change_confirmation_sent');
|
||||
// Unconfirmed until the emailed link is followed.
|
||||
expect(window.user.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('leaves the code out for an account without 2FA', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
dialogs.openChangeEmailDialog();
|
||||
expect($('.dashboard-form-field[data-field="code"]').prop('hidden')).toBe(true);
|
||||
|
||||
await submitWith({ email: '[email protected]', password: 'pw' });
|
||||
|
||||
expect(bodyOf(0)).not.toHaveProperty('code');
|
||||
});
|
||||
|
||||
it('reveals the code field when the server asks for a second factor', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(400, { factor: 'otp', code: 'bad_request', message: 'Enter your code' }));
|
||||
dialogs.openChangeEmailDialog();
|
||||
|
||||
await submitWith({ email: '[email protected]', password: 'pw' });
|
||||
|
||||
expect($('.dashboard-form-field[data-field="code"]').prop('hidden')).toBe(false);
|
||||
expect(status()).toBe('Enter your code');
|
||||
expect(document.activeElement).toBe(field('code').get(0));
|
||||
expect(window.handleReauthRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create API token', () => {
|
||||
it('needs a label', async () => {
|
||||
dialogs.openCreateTokenDialog();
|
||||
await submitWith({ label: ' ' });
|
||||
expect(status()).toBe('token_label_required');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints the token and shows it once with a copy button', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { token: 'tok-abc' }));
|
||||
const writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
dialogs.openCreateTokenDialog();
|
||||
|
||||
await submitWith({ label: ' CLI ', expiresIn: '30d' });
|
||||
|
||||
expect(String(fetch.mock.calls[0][0])).toBe('https://api.puter.test/auth/create-access-token');
|
||||
expect(bodyOf(0)).toMatchObject({ label: 'CLI', expiresIn: '30d' });
|
||||
expect($('.dashboard-token-value').val()).toBe('tok-abc');
|
||||
|
||||
$('.dashboard-token-copy').trigger('click');
|
||||
await settle();
|
||||
expect(writeText).toHaveBeenCalledWith('tok-abc');
|
||||
expect($('.dashboard-token-copy').text()).toBe('token_copied');
|
||||
});
|
||||
|
||||
it('shows why minting failed', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(403, { message: 'Verify your email first' }));
|
||||
dialogs.openCreateTokenDialog();
|
||||
|
||||
await submitWith({ label: 'CLI' });
|
||||
|
||||
expect(status()).toBe('Verify your email first');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete account', () => {
|
||||
it('asks a temporary account to type confirm', async () => {
|
||||
// What the server sends for a temporary account: no password, so `oidc_only` too.
|
||||
window.user = { username: 'guest', is_temp: true, oidc_only: true };
|
||||
fetch.mockResolvedValueOnce(reply(200, { success: true }));
|
||||
dialogs.openDeleteAccountDialog();
|
||||
|
||||
await submitWith({ confirm: 'nope' });
|
||||
expect(status()).toBe('type_confirm_to_delete_account');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
await submitWith({ confirm: ' Confirm ' });
|
||||
expect(openRevalidatePopup).not.toHaveBeenCalled();
|
||||
expect(bodyOf(0)).toEqual({});
|
||||
expect(window.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('needs the password of a password account, then signs out', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { success: true }));
|
||||
dialogs.openDeleteAccountDialog();
|
||||
expect($('.dashboard-form-submit').hasClass('dashboard-dialog-btn-danger')).toBe(true);
|
||||
|
||||
await submitWith();
|
||||
expect(status()).toBe('all_fields_required');
|
||||
|
||||
await submitWith({ password: 'pw' });
|
||||
expect(bodyOf(0)).toEqual({ password: 'pw' });
|
||||
expect(window.user.deleted).toBe(true);
|
||||
expect(window.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A hard logout here would read as "the account is gone".
|
||||
it('prompts sign-in rather than signing out when the session cookie cannot be restored', async () => {
|
||||
fetch
|
||||
.mockResolvedValueOnce(reply(401, { code: 'token_missing' }))
|
||||
.mockResolvedValueOnce(reply(401));
|
||||
dialogs.openDeleteAccountDialog();
|
||||
|
||||
await submitWith({ password: 'pw' });
|
||||
|
||||
expect(window.handleReauthRequired).toHaveBeenCalled();
|
||||
expect(window.logout).not.toHaveBeenCalled();
|
||||
expect(window.user.deleted).toBeUndefined();
|
||||
expect(status()).toBe('reauth_required_message');
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,12 @@
|
||||
// share and properties modals, not a UIWindow. Resolves with the pressed
|
||||
// button's value (or the input's text), and `false` when dismissed.
|
||||
|
||||
const SVG = (body) =>
|
||||
import mountDashboardOverlay from './dashboardOverlay.js';
|
||||
|
||||
export const SVG = (body) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||
|
||||
const ICONS = {
|
||||
export const ICONS = {
|
||||
close: SVG('<path d="M18 6L6 18"/><path d="M6 6l12 12"/>'),
|
||||
danger: SVG('<path d="M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
||||
warning: SVG('<circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/>'),
|
||||
@@ -51,7 +53,6 @@ const BUTTON_TYPES = new Set(['primary', 'danger', 'quiet']);
|
||||
*/
|
||||
export default function UIDashboardDialog ({ title, message, tone, buttons, input, $container, returnFocusTo }) {
|
||||
const { html_encode } = window;
|
||||
const $root = $container && $container.length ? $container : $('body');
|
||||
const id = window.uuidv4?.() ?? String(Date.now());
|
||||
const toneClass = TONES.has(tone) ? ` dashboard-dialog-${tone}` : '';
|
||||
const actions = buttons?.length
|
||||
@@ -59,8 +60,7 @@ export default function UIDashboardDialog ({ title, message, tone, buttons, inpu
|
||||
: [{ label: i18n('ok'), value: true, type: 'primary' }];
|
||||
const primary = actions.find(b => b.type === 'primary' || b.type === 'danger') ?? actions[actions.length - 1];
|
||||
|
||||
let h = '<div class="dashboard-dialog-overlay">';
|
||||
h += `<div class="dashboard-dialog${toneClass}" role="${input ? 'dialog' : 'alertdialog'}" aria-modal="true" tabindex="-1"`;
|
||||
let h = `<div class="dashboard-dialog${toneClass}" role="${input ? 'dialog' : 'alertdialog'}" aria-modal="true" tabindex="-1"`;
|
||||
h += ` aria-labelledby="dashboard-dialog-title-${id}" aria-describedby="dashboard-dialog-message-${id}">`;
|
||||
if ( title ) {
|
||||
h += '<div class="dashboard-dialog-header">';
|
||||
@@ -85,33 +85,26 @@ export default function UIDashboardDialog ({ title, message, tone, buttons, inpu
|
||||
const type = BUTTON_TYPES.has(button.type) ? button.type : 'quiet';
|
||||
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-${type}" data-index="${index}">${html_encode(button.label)}</button>`;
|
||||
});
|
||||
h += '</div></div></div>';
|
||||
|
||||
const $overlay = $(h);
|
||||
$root.append($overlay);
|
||||
requestAnimationFrame(() => $overlay.addClass('dashboard-dialog-show'));
|
||||
|
||||
const el_previous_focus = document.activeElement;
|
||||
const $input = $overlay.find('.dashboard-dialog-input');
|
||||
// A prompt wants the text; anything else, the safest button.
|
||||
const $first = $input.length
|
||||
? $input
|
||||
: $overlay.find('.dashboard-dialog-btn-quiet').first().add($overlay.find('.dashboard-dialog-btn').first()).first();
|
||||
($first.get(0) ?? $overlay.find('.dashboard-dialog').get(0))?.focus({ preventScroll: true });
|
||||
if ( $input.length ) $input.get(0).select();
|
||||
h += '</div></div>';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let closed = false;
|
||||
const { $overlay, close: closeOverlay } = mountDashboardOverlay({
|
||||
html: h,
|
||||
$container,
|
||||
returnFocusTo,
|
||||
onDismiss: () => resolve(false),
|
||||
});
|
||||
|
||||
const $input = $overlay.find('.dashboard-dialog-input');
|
||||
// A prompt wants the text; anything else, the safest button.
|
||||
const $first = $input.length
|
||||
? $input
|
||||
: $overlay.find('.dashboard-dialog-btn-quiet').first().add($overlay.find('.dashboard-dialog-btn').first()).first();
|
||||
($first.get(0) ?? $overlay.find('.dashboard-dialog').get(0))?.focus({ preventScroll: true });
|
||||
if ( $input.length ) $input.get(0).select();
|
||||
|
||||
const close = (value) => {
|
||||
if ( closed ) return;
|
||||
closed = true;
|
||||
$overlay.removeClass('dashboard-dialog-show');
|
||||
$(document).off(`keydown.dashboard-dialog-${id}`);
|
||||
setTimeout(() => $overlay.remove(), 200);
|
||||
const el_focus_target = [returnFocusTo, el_previous_focus].find((el) => el && document.contains(el));
|
||||
try {
|
||||
el_focus_target?.focus({ preventScroll: true });
|
||||
} catch { /* focus restoration is best-effort */ }
|
||||
closeOverlay();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
@@ -124,49 +117,11 @@ export default function UIDashboardDialog ({ title, message, tone, buttons, inpu
|
||||
$overlay.on('click', '.dashboard-dialog-btn', function () {
|
||||
submit(actions[Number($(this).attr('data-index'))]);
|
||||
});
|
||||
$overlay.on('click', '.dashboard-dialog-close', () => close(false));
|
||||
|
||||
// Backdrop close goes by where the press started, so a drag that
|
||||
// starts in the card and ends outside does not dismiss it.
|
||||
let backdrop_pressed = false;
|
||||
$overlay.on('mousedown', function (e) {
|
||||
backdrop_pressed = e.target === $overlay[0];
|
||||
// A press inside the overlay must not let initgui's global mousedown
|
||||
// hand focus (and the z-index) back to the window underneath it.
|
||||
window.mouseover_window = undefined;
|
||||
});
|
||||
$overlay.on('click', function (e) {
|
||||
if ( e.target === $overlay[0] && backdrop_pressed ) close(false);
|
||||
});
|
||||
$(document).on(`keydown.dashboard-dialog-${id}`, function (e) {
|
||||
if ( e.key === 'Escape' ) close(false);
|
||||
});
|
||||
$input.on('keydown', function (e) {
|
||||
if ( e.key === 'Enter' ) {
|
||||
e.preventDefault();
|
||||
submit(primary);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep Tab cycling inside the dialog while it's up.
|
||||
$overlay.on('keydown', function (e) {
|
||||
if ( e.key !== 'Tab' ) return;
|
||||
const focusables = $overlay
|
||||
.find('button, input, [tabindex]:not([tabindex="-1"])')
|
||||
.filter(':visible:not(:disabled)');
|
||||
if ( ! focusables.length ) return;
|
||||
const first = focusables.get(0);
|
||||
const last = focusables.get(focusables.length - 1);
|
||||
if ( focusables.index(document.activeElement) === -1 ) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if ( e.shiftKey && document.activeElement === first ) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if ( ! e.shiftKey && document.activeElement === last ) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// A dashboard dialog holding a small form: the same card as the confirm and
|
||||
// prompt dialogs (a centered card on desktop, a bottom sheet on phones),
|
||||
// with labelled fields, a status line, and a submit button that shows
|
||||
// progress while the caller's handler runs.
|
||||
|
||||
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
import mountDashboardOverlay from './dashboardOverlay.js';
|
||||
import { ICONS, SVG } from './UIDashboardDialog.js';
|
||||
|
||||
const FORM_ICONS = {
|
||||
done: SVG('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5L16 9.5"/>'),
|
||||
show: SVG('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
||||
hide: SVG('<path d="M17.9 17.9A10.4 10.4 0 0 1 12 19c-6.4 0-10-7-10-7a18.5 18.5 0 0 1 5.1-5.9"/><path d="M9.9 5.2A9.1 9.1 0 0 1 12 5c6.4 0 10 7 10 7a18.6 18.6 0 0 1-2.2 3.2"/><path d="M14.1 14.1a3 3 0 1 1-4.2-4.2"/><path d="M2 2l20 20"/>'),
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {object} FormDialogField
|
||||
* @property {string} name Key in `values()`.
|
||||
* @property {string} label HTML the caller has already encoded.
|
||||
* @property {'text'|'email'|'password'|'textarea'|'select'} [type] Defaults to text.
|
||||
* @property {string} [value]
|
||||
* @property {string} [placeholder] Already encoded.
|
||||
* @property {string} [hint] HTML the caller has already encoded, shown under the field.
|
||||
* @property {boolean} [hidden] Rendered but not shown; see `showField`.
|
||||
* @property {Record<string, string|number|boolean>} [attrs] Extra attributes
|
||||
* for the control (`autocomplete`, `maxlength`, `inputmode`, ...).
|
||||
* @property {Array<{ value: string, label: string }>} [options] A select's
|
||||
* choices; labels already encoded.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} FormDialog
|
||||
* @property {jQuery} $dialog
|
||||
* @property {() => void} close
|
||||
* @property {() => Record<string, string>} values Every field's current value.
|
||||
* @property {(name: string) => jQuery} field A field's control.
|
||||
* @property {(name: string, show: boolean) => void} showField
|
||||
* @property {(text: string, opts?: { kind?: 'error'|'info', field?: string }) => void} setStatus
|
||||
* Plain text; an empty string clears it. Naming a field marks it invalid and focuses it.
|
||||
* @property {(opts: { message?: string, html?: string }) => void} finish Swaps the
|
||||
* form for a done state (a plain-text `message`, or the caller's encoded
|
||||
* `html`) with a single Done button.
|
||||
*/
|
||||
|
||||
const attrs_html = (attrs) => Object.entries(attrs ?? {})
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== false)
|
||||
.map(([key, value]) => (value === true ? ` ${key}` : ` ${key}="${html_encode(String(value))}"`))
|
||||
.join('');
|
||||
|
||||
const field_html = (field, id) => {
|
||||
const control_id = `${id}-${field.name}`;
|
||||
const hint_id = `${control_id}-hint`;
|
||||
const type = field.type ?? 'text';
|
||||
const common = ` id="${control_id}" name="${html_encode(field.name)}" class="dashboard-dialog-input dashboard-form-input"`
|
||||
+ (field.hint ? ` aria-describedby="${hint_id}"` : '')
|
||||
+ attrs_html(field.attrs);
|
||||
|
||||
let control;
|
||||
if ( type === 'textarea' ) {
|
||||
control = `<textarea${common} placeholder="${field.placeholder ?? ''}">${html_encode(field.value ?? '')}</textarea>`;
|
||||
} else if ( type === 'select' ) {
|
||||
control = `<select${common}>${(field.options ?? []).map((option) =>
|
||||
`<option value="${html_encode(option.value)}"${option.value === field.value ? ' selected' : ''}>${option.label}</option>`).join('')}</select>`;
|
||||
} else {
|
||||
control = `<input type="${type}"${common} placeholder="${field.placeholder ?? ''}" value="${html_encode(field.value ?? '')}">`;
|
||||
}
|
||||
if ( type === 'password' ) {
|
||||
control = '<div class="dashboard-form-secret">'
|
||||
+ control
|
||||
+ `<button type="button" class="dashboard-form-reveal" aria-controls="${control_id}"`
|
||||
+ ` aria-label="${i18n('show_password')}" title="${i18n('show_password')}">${FORM_ICONS.show}</button>`
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
return `<div class="dashboard-form-field" data-field="${html_encode(field.name)}"${field.hidden ? ' hidden' : ''}>`
|
||||
+ `<label class="dashboard-form-label" for="${control_id}">${field.label}</label>`
|
||||
+ control
|
||||
+ (field.hint ? `<p class="dashboard-form-hint" id="${hint_id}">${field.hint}</p>` : '')
|
||||
+ '</div>';
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.title Plain text.
|
||||
* @param {string} [opts.intro] HTML the caller has already encoded, above the fields.
|
||||
* @param {'danger'} [opts.tone] Adds the warning icon and makes the submit button red.
|
||||
* @param {FormDialogField[]} [opts.fields]
|
||||
* @param {string} opts.submitLabel Plain text.
|
||||
* @param {(dialog: FormDialog) => Promise<void> | void} opts.onSubmit Runs on
|
||||
* the submit button or Enter, with the fields locked and the button busy
|
||||
* until it settles. It reports back through the dialog: `setStatus`,
|
||||
* `finish`, or `close`.
|
||||
* @param {jQuery} [opts.$container] Where the overlay mounts; defaults to body.
|
||||
* @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close.
|
||||
* @returns {FormDialog}
|
||||
*/
|
||||
export default function UIDashboardFormDialog ({ title, intro, tone, fields = [], submitLabel, onSubmit, $container, returnFocusTo }) {
|
||||
const id = `dashboard-form-${window.uuidv4?.() ?? Date.now()}`;
|
||||
const danger = tone === 'danger';
|
||||
|
||||
let h = `<form class="dashboard-dialog dashboard-form-dialog${danger ? ' dashboard-dialog-danger' : ''}" role="dialog" aria-modal="true" tabindex="-1" novalidate`;
|
||||
h += ` aria-labelledby="${id}-title"${intro ? ` aria-describedby="${id}-intro"` : ''}>`;
|
||||
h += '<div class="dashboard-dialog-header">';
|
||||
h += `<span class="dashboard-dialog-title" id="${id}-title">${html_encode(title)}</span>`;
|
||||
h += `<button type="button" class="dashboard-dialog-close" aria-label="${i18n('close')}" title="${i18n('close')}">${ICONS.close}</button>`;
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-dialog-body">';
|
||||
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 += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += '</div></div>';
|
||||
h += '<div class="dashboard-dialog-footer">';
|
||||
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-form-cancel">${i18n('cancel')}</button>`;
|
||||
h += `<button type="submit" class="dashboard-dialog-btn dashboard-dialog-btn-${danger ? 'danger' : 'primary'} dashboard-form-submit">`;
|
||||
h += '<span class="dashboard-form-spinner" aria-hidden="true"></span>';
|
||||
h += `<span class="dashboard-form-submit-label">${html_encode(submitLabel)}</span>`;
|
||||
h += '</button>';
|
||||
h += '</div></form>';
|
||||
|
||||
const { $dialog, close, isClosed } = mountDashboardOverlay({ html: h, $container, returnFocusTo });
|
||||
const $status = $dialog.find('.dashboard-form-status');
|
||||
const $submit = $dialog.find('.dashboard-form-submit');
|
||||
const $controls = () => $dialog.find('.dashboard-form-input');
|
||||
|
||||
let busy = false;
|
||||
/** @type {HTMLElement | null} */
|
||||
let focus_after = null;
|
||||
|
||||
/** @type {FormDialog} */
|
||||
const dialog = {
|
||||
$dialog,
|
||||
close,
|
||||
values () {
|
||||
const out = {};
|
||||
$controls().each(function () {
|
||||
out[this.name] = String($(this).val() ?? '');
|
||||
});
|
||||
return out;
|
||||
},
|
||||
field: (name) => $controls().filter((_, el) => el.name === name),
|
||||
showField (name, show) {
|
||||
$dialog.find('.dashboard-form-field').filter((_, el) => el.dataset.field === name).prop('hidden', ! show);
|
||||
},
|
||||
setStatus (text, { kind = 'error', field } = {}) {
|
||||
$status.text(text ?? '')
|
||||
.toggleClass('dashboard-form-status-error', !! text && kind === 'error')
|
||||
.toggleClass('dashboard-form-status-info', !! text && kind === 'info');
|
||||
if ( ! text ) return;
|
||||
const body = $dialog.find('.dashboard-dialog-body').get(0);
|
||||
if ( body ) body.scrollTop = body.scrollHeight;
|
||||
if ( field ) {
|
||||
const $field = dialog.field(field).attr('aria-invalid', 'true');
|
||||
// The status sits below the fields; on a short screen, the
|
||||
// field wins the space.
|
||||
const el_row = $field.closest('.dashboard-form-field').get(0);
|
||||
if ( body && el_row ) {
|
||||
const hidden_above = body.getBoundingClientRect().top - el_row.getBoundingClientRect().top;
|
||||
if ( hidden_above > 0 ) body.scrollTop -= hidden_above;
|
||||
}
|
||||
// A locked field can't take focus; the submit handler hands
|
||||
// it over once the fields are unlocked again.
|
||||
if ( busy ) focus_after = $field.get(0);
|
||||
else $field.get(0)?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
finish ({ message, html }) {
|
||||
const content = html ?? `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message ?? '')}</p></div>`;
|
||||
$dialog.attr('aria-describedby', null);
|
||||
$dialog.find('.dashboard-dialog-body').html(`<div class="dashboard-dialog-content" role="status">${content}</div>`);
|
||||
// A status region that arrives with its text isn't announced;
|
||||
// the focused Done button reads the message out instead.
|
||||
$dialog.find('.dashboard-dialog-footer').html(
|
||||
`<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary dashboard-form-done-btn"${html ? '' : ` aria-describedby="${id}-done"`}>${i18n('done')}</button>`);
|
||||
$dialog.find('.dashboard-form-done-btn').get(0)?.focus({ preventScroll: true });
|
||||
},
|
||||
};
|
||||
|
||||
// A keyboard popping up mid-animation on a phone hides half the sheet;
|
||||
// there the user taps the field they want.
|
||||
const $first_field = $controls().filter(':visible').first();
|
||||
(isTouchPrimaryDevice() || ! $first_field.length ? $dialog : $first_field).get(0)?.focus({ preventScroll: true });
|
||||
|
||||
$dialog.on('input change', '.dashboard-form-input', function () {
|
||||
$(this).removeAttr('aria-invalid');
|
||||
});
|
||||
|
||||
$dialog.on('click', '.dashboard-form-cancel, .dashboard-form-done-btn', () => close());
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
$dialog.on('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if ( busy || isClosed() ) return;
|
||||
busy = true;
|
||||
dialog.setStatus('');
|
||||
// Disabling the focused control drops focus to the page; it gets it back after.
|
||||
const el_had_focus = document.activeElement;
|
||||
const $locked = $controls().not(':disabled');
|
||||
$locked.prop('disabled', true);
|
||||
$submit.prop('disabled', true).addClass('dashboard-form-busy').attr('aria-busy', 'true');
|
||||
try {
|
||||
await onSubmit(dialog);
|
||||
} catch ( err ) {
|
||||
dialog.setStatus(err?.message || i18n('error_unknown_cause', [], false));
|
||||
} finally {
|
||||
busy = false;
|
||||
$locked.prop('disabled', false);
|
||||
$submit.prop('disabled', false).removeClass('dashboard-form-busy').removeAttr('aria-busy');
|
||||
const el_dialog = $dialog.get(0);
|
||||
const el_refocus = focus_after
|
||||
?? (el_dialog.contains(document.activeElement) ? null : el_had_focus);
|
||||
focus_after = null;
|
||||
if ( ! isClosed() && el_dialog.contains(el_refocus) ) el_refocus.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
return dialog;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => 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 });
|
||||
|
||||
const { default: UIDashboardFormDialog } = await import('./UIDashboardFormDialog.js');
|
||||
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const submit = () => $('.dashboard-form-dialog').trigger('submit');
|
||||
|
||||
const open = (opts = {}) => UIDashboardFormDialog({
|
||||
title: 'Change it',
|
||||
submitLabel: 'Save',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', value: 'Ada <3' },
|
||||
{ name: 'secret', type: 'password', label: 'Password' },
|
||||
{ name: 'about', type: 'textarea', label: 'About', value: 'hi' },
|
||||
{ name: 'when', type: 'select', label: 'When', value: '7d', options: [{ value: '', label: 'Never' }, { value: '7d', label: 'A week' }] },
|
||||
{ name: 'code', label: 'Code', hidden: true },
|
||||
],
|
||||
onSubmit: () => {},
|
||||
...opts,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('UIDashboardFormDialog', () => {
|
||||
it('renders a dashboard dialog card with labelled fields, not a window', () => {
|
||||
open();
|
||||
expect($('.dashboard-dialog-overlay .dashboard-form-dialog')).toHaveLength(1);
|
||||
expect($('.window')).toHaveLength(0);
|
||||
expect($('.dashboard-dialog-title').text()).toBe('Change it');
|
||||
const $name = $('.dashboard-form-input[name="name"]');
|
||||
expect($(`label[for="${$name.attr('id')}"]`).text()).toBe('Name');
|
||||
expect($name.val()).toBe('Ada <3');
|
||||
expect($('.dashboard-form-field[data-field="code"]').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('reads every field back by name', () => {
|
||||
const dialog = open();
|
||||
expect(dialog.values()).toEqual({ name: 'Ada <3', secret: '', about: 'hi', when: '7d', code: '' });
|
||||
});
|
||||
|
||||
it('locks the fields and shows progress while the handler runs', async () => {
|
||||
let release;
|
||||
const onSubmit = vi.fn(() => new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}));
|
||||
open({ onSubmit });
|
||||
|
||||
submit();
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
expect($('.dashboard-form-input[name="name"]').prop('disabled')).toBe(true);
|
||||
expect($('.dashboard-form-submit').hasClass('dashboard-form-busy')).toBe(true);
|
||||
// A second press while busy is ignored.
|
||||
submit();
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
|
||||
release();
|
||||
await tick();
|
||||
expect($('.dashboard-form-input[name="name"]').prop('disabled')).toBe(false);
|
||||
expect($('.dashboard-form-submit').hasClass('dashboard-form-busy')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the named field invalid and focuses it once the fields unlock', async () => {
|
||||
open({ onSubmit: (dialog) => dialog.setStatus('Nope', { field: 'secret' }) });
|
||||
|
||||
submit();
|
||||
await tick();
|
||||
|
||||
expect($('.dashboard-form-status').text()).toBe('Nope');
|
||||
expect($('.dashboard-form-status').hasClass('dashboard-form-status-error')).toBe(true);
|
||||
const secret = $('.dashboard-form-input[name="secret"]');
|
||||
expect(secret.attr('aria-invalid')).toBe('true');
|
||||
expect(document.activeElement).toBe(secret.get(0));
|
||||
|
||||
secret.val('x').trigger('input');
|
||||
expect(secret.attr('aria-invalid')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives focus back to the submitting control when locking it dropped focus', async () => {
|
||||
open({ onSubmit: (dialog) => {
|
||||
// A browser drops focus to the page when the focused control is
|
||||
// disabled; jsdom doesn't, so move it there by hand.
|
||||
const el_away = $('<button>').appendTo('body').get(0);
|
||||
el_away.focus();
|
||||
el_away.remove();
|
||||
dialog.setStatus('Server says no');
|
||||
} });
|
||||
const name = $('.dashboard-form-input[name="name"]').get(0);
|
||||
name.focus();
|
||||
submit();
|
||||
await tick();
|
||||
expect(document.activeElement).toBe(name);
|
||||
});
|
||||
|
||||
it('leaves focus where the handler put it inside the dialog', async () => {
|
||||
open({ onSubmit: (dialog) => dialog.finish({ message: 'Done' }) });
|
||||
$('.dashboard-form-input[name="name"]').get(0).focus();
|
||||
submit();
|
||||
await tick();
|
||||
expect(document.activeElement).toBe($('.dashboard-form-done-btn').get(0));
|
||||
});
|
||||
|
||||
it('scrolls a named field back into view when the status pushed it off the top', () => {
|
||||
const dialog = open();
|
||||
const body = $('.dashboard-dialog-body').get(0);
|
||||
const row = $('.dashboard-form-field[data-field="name"]').get(0);
|
||||
Object.defineProperty(body, 'scrollHeight', { value: 500 });
|
||||
let scroll_top = 0;
|
||||
Object.defineProperty(body, 'scrollTop', { get: () => scroll_top, set: (v) => {
|
||||
scroll_top = v;
|
||||
} });
|
||||
body.getBoundingClientRect = () => ({ top: 100 });
|
||||
row.getBoundingClientRect = () => ({ top: 100 - 60 });
|
||||
|
||||
dialog.setStatus('Nope', { field: 'name' });
|
||||
expect(scroll_top).toBe(500 - 60);
|
||||
});
|
||||
|
||||
it('shows a thrown error in the status line', async () => {
|
||||
open({ onSubmit: async () => {
|
||||
throw new Error('Server says no');
|
||||
} });
|
||||
submit();
|
||||
await tick();
|
||||
expect($('.dashboard-form-status').text()).toBe('Server says no');
|
||||
});
|
||||
|
||||
it('swaps the form for a done state whose button closes it', async () => {
|
||||
open({ onSubmit: (dialog) => dialog.finish({ message: 'All <good>' }) });
|
||||
submit();
|
||||
await tick();
|
||||
|
||||
expect($('.dashboard-form-input')).toHaveLength(0);
|
||||
expect($('.dashboard-form-done p').text()).toBe('All <good>');
|
||||
expect(document.activeElement).toBe($('.dashboard-form-done-btn').get(0));
|
||||
const described_by = $('.dashboard-form-done-btn').attr('aria-describedby');
|
||||
expect($(`#${described_by}`).text()).toBe('All <good>');
|
||||
$('.dashboard-form-done-btn').trigger('click');
|
||||
expect($('.dashboard-dialog-overlay').hasClass('dashboard-dialog-show')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles a password between hidden and shown', () => {
|
||||
open();
|
||||
const $input = $('.dashboard-form-input[name="secret"]');
|
||||
const $reveal = $('.dashboard-form-reveal');
|
||||
$reveal.trigger('click');
|
||||
expect($input.attr('type')).toBe('text');
|
||||
expect($reveal.attr('aria-label')).toBe('hide_password');
|
||||
// A label that flips with the state, so no aria-pressed on top of it.
|
||||
expect($reveal.attr('aria-pressed')).toBeUndefined();
|
||||
$reveal.trigger('click');
|
||||
expect($input.attr('type')).toBe('password');
|
||||
expect($reveal.attr('aria-label')).toBe('show_password');
|
||||
});
|
||||
|
||||
it('closes on Cancel and on Escape without submitting', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
open({ onSubmit });
|
||||
await tick();
|
||||
$('.dashboard-form-cancel').trigger('click');
|
||||
expect($('.dashboard-dialog-overlay').hasClass('dashboard-dialog-show')).toBe(false);
|
||||
|
||||
document.body.innerHTML = '';
|
||||
open({ onSubmit });
|
||||
await tick();
|
||||
$(document).trigger($.Event('keydown', { key: 'Escape' }));
|
||||
expect($('.dashboard-dialog-overlay').hasClass('dashboard-dialog-show')).toBe(false);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives the danger tone a warning icon and a red submit button', () => {
|
||||
open({ tone: 'danger' });
|
||||
expect($('.dashboard-dialog-icon')).toHaveLength(1);
|
||||
expect($('.dashboard-form-submit').hasClass('dashboard-dialog-btn-danger')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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 shell every `.dashboard-dialog` card shares: a backdrop that fades in,
|
||||
// and the ways out of it. What goes inside the card is the caller's.
|
||||
|
||||
let overlay_seq = 0;
|
||||
|
||||
/**
|
||||
* Mounts `.dashboard-dialog-overlay` around the given card and wires the
|
||||
* reveal transition, dismissal (Escape, `.dashboard-dialog-close`, a press
|
||||
* and release on the backdrop), a Tab focus trap, room for an on-screen
|
||||
* keyboard, and focus restoration on close.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.html The `.dashboard-dialog` card's markup.
|
||||
* @param {jQuery} [opts.$container] Where the overlay mounts; defaults to body.
|
||||
* @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close; falls
|
||||
* back to whatever had it when the overlay opened.
|
||||
* @param {() => void} [opts.onDismiss] Runs when the user dismisses the
|
||||
* overlay, after it has closed. Not called for `close()`.
|
||||
* @returns {{ $overlay: jQuery, $dialog: jQuery, close: () => void, isClosed: () => boolean }}
|
||||
*/
|
||||
export default function mountDashboardOverlay ({ html, $container, returnFocusTo, onDismiss }) {
|
||||
const $root = $container && $container.length ? $container : $('body');
|
||||
const ns = `.dashboard-overlay-${++overlay_seq}`;
|
||||
const el_previous_focus = document.activeElement;
|
||||
|
||||
const $overlay = $(`<div class="dashboard-dialog-overlay">${html}</div>`);
|
||||
const $dialog = $overlay.children('.dashboard-dialog');
|
||||
const el_overlay = $overlay.get(0);
|
||||
$root.append($overlay);
|
||||
|
||||
// iOS lays its keyboard over the page rather than resizing it, which
|
||||
// would leave a bottom sheet's fields and buttons underneath. Pin the
|
||||
// overlay to the part of the screen that is still visible.
|
||||
const viewport = window.visualViewport;
|
||||
const fitToViewport = () => {
|
||||
const covered = window.innerHeight - viewport.height - viewport.offsetTop;
|
||||
const pinned = covered > 1 || viewport.offsetTop > 1;
|
||||
el_overlay.style.top = pinned ? `${viewport.offsetTop}px` : '';
|
||||
el_overlay.style.height = pinned ? `${viewport.height}px` : '';
|
||||
el_overlay.style.bottom = pinned ? 'auto' : '';
|
||||
$overlay.toggleClass('dashboard-dialog-overlay-keyboard', covered > 1);
|
||||
};
|
||||
viewport?.addEventListener('resize', fitToViewport);
|
||||
viewport?.addEventListener('scroll', fitToViewport);
|
||||
|
||||
let closed = false;
|
||||
// Reveal after paint so the CSS transition runs, unless it was closed first.
|
||||
requestAnimationFrame(() => {
|
||||
if ( ! closed ) $overlay.addClass('dashboard-dialog-show');
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
if ( closed ) return;
|
||||
closed = true;
|
||||
$overlay.removeClass('dashboard-dialog-show');
|
||||
$(document).off(`keydown${ns}`);
|
||||
viewport?.removeEventListener('resize', fitToViewport);
|
||||
viewport?.removeEventListener('scroll', fitToViewport);
|
||||
setTimeout(() => $overlay.remove(), 200);
|
||||
const el_focus_target = [returnFocusTo, el_previous_focus].find((el) => el && document.contains(el));
|
||||
try {
|
||||
el_focus_target?.focus({ preventScroll: true });
|
||||
} catch { /* focus restoration is best-effort */ }
|
||||
};
|
||||
const dismiss = () => {
|
||||
if ( closed ) return;
|
||||
close();
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
$overlay.on('click', '.dashboard-dialog-close', dismiss);
|
||||
|
||||
// Backdrop close goes by where the press started, so a drag that
|
||||
// starts in the card and ends outside does not dismiss it. The second
|
||||
// press of a double-click on the button that opened it doesn't count.
|
||||
let backdrop_pressed = false;
|
||||
$overlay.on('mousedown', function (e) {
|
||||
backdrop_pressed = e.target === el_overlay && (e.detail ?? 0) < 2;
|
||||
// A press inside the overlay must not let initgui's global mousedown
|
||||
// hand focus (and the z-index) back to the window underneath it.
|
||||
window.mouseover_window = undefined;
|
||||
});
|
||||
$overlay.on('click', function (e) {
|
||||
if ( e.target === el_overlay && backdrop_pressed ) dismiss();
|
||||
});
|
||||
|
||||
$(document).on(`keydown${ns}`, function (e) {
|
||||
if ( e.key !== 'Escape' ) return;
|
||||
// Escape belongs to whatever is on top: a window or modal opened over
|
||||
// this overlay closes itself, not the overlay underneath it. Not all
|
||||
// of them take focus, so check what is painted over the overlay too.
|
||||
const el_active = document.activeElement;
|
||||
if ( el_active && el_active !== document.body && ! el_overlay.contains(el_active) ) return;
|
||||
const el_top = document.elementFromPoint?.(window.innerWidth / 2, window.innerHeight / 2);
|
||||
if ( el_top && ! el_overlay.contains(el_top) ) return;
|
||||
dismiss();
|
||||
});
|
||||
|
||||
// Keep Tab cycling inside the card while it's up.
|
||||
$overlay.on('keydown', function (e) {
|
||||
if ( e.key !== 'Tab' ) return;
|
||||
const focusables = $dialog
|
||||
.find('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])')
|
||||
.filter(':visible:not(:disabled)');
|
||||
if ( ! focusables.length ) return;
|
||||
const first = focusables.get(0);
|
||||
const last = focusables.get(focusables.length - 1);
|
||||
if ( focusables.index(document.activeElement) === -1 ) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if ( e.shiftKey && document.activeElement === first ) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if ( ! e.shiftKey && document.activeElement === last ) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return { $overlay, $dialog, close, isClosed: () => closed };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
|
||||
const { default: mountDashboardOverlay } = await import('./dashboardOverlay.js');
|
||||
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const card = '<div class="dashboard-dialog" tabindex="-1"><button class="dashboard-dialog-close">x</button><input class="inside"></div>';
|
||||
const escape = () => $(document).trigger($.Event('keydown', { key: 'Escape' }));
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
delete window.visualViewport;
|
||||
});
|
||||
|
||||
describe('mountDashboardOverlay', () => {
|
||||
it('reveals after a frame, but not once it has already closed', async () => {
|
||||
const a = mountDashboardOverlay({ html: card });
|
||||
await tick();
|
||||
expect(a.$overlay.hasClass('dashboard-dialog-show')).toBe(true);
|
||||
|
||||
const b = mountDashboardOverlay({ html: card });
|
||||
b.close();
|
||||
await tick();
|
||||
expect(b.$overlay.hasClass('dashboard-dialog-show')).toBe(false);
|
||||
expect(b.isClosed()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports dismissal by Escape or the close button, but not close()', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const a = mountDashboardOverlay({ html: card, onDismiss });
|
||||
a.close();
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
mountDashboardOverlay({ html: card, onDismiss });
|
||||
escape();
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
|
||||
mountDashboardOverlay({ html: card, onDismiss });
|
||||
$('.dashboard-dialog-close').last().trigger('click');
|
||||
expect(onDismiss).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A window opened from the overlay (the session manager, say) owns its own Escape.
|
||||
it('leaves Escape alone while focus is somewhere outside it', () => {
|
||||
document.body.innerHTML = '<input class="elsewhere">';
|
||||
const onDismiss = vi.fn();
|
||||
const { isClosed } = mountDashboardOverlay({ html: card, onDismiss });
|
||||
$('.elsewhere').get(0).focus();
|
||||
escape();
|
||||
expect(isClosed()).toBe(false);
|
||||
|
||||
$('.inside').get(0).focus();
|
||||
escape();
|
||||
expect(isClosed()).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves Escape alone while something else is painted over it', () => {
|
||||
document.body.innerHTML = '<div class="on-top"></div>';
|
||||
const { isClosed } = mountDashboardOverlay({ html: card });
|
||||
document.elementFromPoint = () => $('.on-top').get(0);
|
||||
escape();
|
||||
expect(isClosed()).toBe(false);
|
||||
|
||||
document.elementFromPoint = () => $('.inside').get(0);
|
||||
escape();
|
||||
expect(isClosed()).toBe(true);
|
||||
delete document.elementFromPoint;
|
||||
});
|
||||
|
||||
it('closes on a backdrop click, but not on the second click of a double-click', () => {
|
||||
const press = (detail) => {
|
||||
const el = $('.dashboard-dialog-overlay').get(0);
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, detail }));
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, detail }));
|
||||
};
|
||||
const a = mountDashboardOverlay({ html: card });
|
||||
press(2);
|
||||
expect(a.isClosed()).toBe(false);
|
||||
press(1);
|
||||
expect(a.isClosed()).toBe(true);
|
||||
});
|
||||
|
||||
it('hands focus back to the given element on close', () => {
|
||||
document.body.innerHTML = '<button class="trigger">open</button>';
|
||||
const trigger = $('.trigger').get(0);
|
||||
const { close } = mountDashboardOverlay({ html: card, returnFocusTo: trigger });
|
||||
$('.inside').get(0).focus();
|
||||
close();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('fits itself above an on-screen keyboard and lets go when it hides', () => {
|
||||
const listeners = {};
|
||||
window.visualViewport = {
|
||||
height: window.innerHeight,
|
||||
offsetTop: 0,
|
||||
addEventListener: (type, fn) => {
|
||||
listeners[type] = fn;
|
||||
},
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const { $overlay, close } = mountDashboardOverlay({ html: card });
|
||||
const el = $overlay.get(0);
|
||||
|
||||
window.visualViewport.height = window.innerHeight - 300;
|
||||
listeners.resize();
|
||||
expect(el.style.height).toBe(`${window.innerHeight - 300}px`);
|
||||
expect(el.style.bottom).toBe('auto');
|
||||
expect($overlay.hasClass('dashboard-dialog-overlay-keyboard')).toBe(true);
|
||||
|
||||
window.visualViewport.height = window.innerHeight;
|
||||
listeners.resize();
|
||||
expect(el.style.height).toBe('');
|
||||
expect($overlay.hasClass('dashboard-dialog-overlay-keyboard')).toBe(false);
|
||||
|
||||
close();
|
||||
expect(window.visualViewport.removeEventListener).toHaveBeenCalledWith('resize', listeners.resize);
|
||||
});
|
||||
});
|
||||
@@ -1,234 +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 { openRevalidatePopup } from '../../util/openid.js';
|
||||
import { fetchWithSessionCookieRetry, isSessionAuthError } from '../../util/sessionAuth.js';
|
||||
import Placeholder from '../../util/Placeholder.js';
|
||||
import PasswordEntry from '../Components/PasswordEntry.js';
|
||||
import UIWindow from '../UIWindow.js';
|
||||
|
||||
// TODO: DRY: We could specify a validator and endpoint instead of writing
|
||||
// a DOM tree and event handlers for each of these. (low priority)
|
||||
async function UIWindowChangeEmail (options) {
|
||||
options = options ?? {};
|
||||
|
||||
const password_entry = new PasswordEntry({});
|
||||
const place_password_entry = Placeholder();
|
||||
|
||||
const internal_id = window.uuidv4();
|
||||
let h = '';
|
||||
h += '<div class="change-email" style="padding: 20px; border-bottom: 1px solid #ced7e1;">';
|
||||
// error msg
|
||||
h += '<div class="form-error-msg"></div>';
|
||||
// success msg
|
||||
h += '<div class="form-success-msg"></div>';
|
||||
// new email
|
||||
h += '<div style="overflow: hidden; margin-top: 10px; margin-bottom: 30px;">';
|
||||
h += `<label for="confirm-new-email-${internal_id}">${i18n('new_email')}</label>`;
|
||||
h += `<input id="confirm-new-email-${internal_id}" type="text" name="new-email" class="new-email" autocomplete="off" />`;
|
||||
h += '</div>';
|
||||
// password / OIDC revalidate
|
||||
h += '<div class="change-email-auth-row" style="overflow: hidden; margin-top: 10px; margin-bottom: 30px;">';
|
||||
h += '<div class="change-email-password-wrap">';
|
||||
h += `<label>${i18n('account_password')}</label>`;
|
||||
h += `${place_password_entry.html}`;
|
||||
h += '</div>';
|
||||
h += '<div class="change-email-oidc-wrap" style="display:none;">';
|
||||
h += '<p class="change-email-oidc-flow-notice" style="margin:0;font-size:12px;color:#666;"></p>';
|
||||
h += '<span class="change-email-revalidated-msg" style="display:none;"></span>';
|
||||
h += '</div>';
|
||||
h += '<p class="change-email-oidc-hint" style="margin-top:6px;font-size:12px;color:#666;display:none;"></p>';
|
||||
h += '</div>';
|
||||
|
||||
h += '<div class="change-email-otp-wrap" style="display:none; margin-bottom:30px;">';
|
||||
h += `<label for="change-email-code-${internal_id}">${i18n('confirm_code_2fa_instruction')}</label>`;
|
||||
h += `<input id="change-email-code-${internal_id}" class="change-email-code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" />`;
|
||||
h += '</div>';
|
||||
|
||||
// Change Email
|
||||
h += `<button class="change-email-btn button button-primary button-block button-normal">${i18n('change_email')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: i18n('change_email'),
|
||||
app: 'change-email',
|
||||
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_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,
|
||||
onAppend: function (this_window) {
|
||||
$(this_window).find('.new-email').get(0)?.focus({ preventScroll: true });
|
||||
if ( window.user?.otp ) $(this_window).find('.change-email-otp-wrap').show();
|
||||
const oidc_only = !!(window.user && window.user.oidc_only);
|
||||
const authRow = $(this_window).find('.change-email-auth-row');
|
||||
if ( oidc_only ) {
|
||||
authRow.find('.change-email-password-wrap').hide();
|
||||
// OIDC: no notice box; user will see revalidation when they continue
|
||||
} else {
|
||||
authRow.find('.change-email-oidc-wrap').hide();
|
||||
}
|
||||
},
|
||||
window_class: 'window-publishWebsite',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': 'rgb(245 247 249)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
},
|
||||
...options.window_options,
|
||||
});
|
||||
|
||||
password_entry.attach(place_password_entry);
|
||||
|
||||
const origin = window.gui_origin || window.api_origin || '';
|
||||
const apiUrl = `${origin}/user-protected/change-email`;
|
||||
let revalidated = false;
|
||||
|
||||
const hint = $(el_window).find('.change-email-oidc-hint');
|
||||
const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.';
|
||||
|
||||
const myOpenRevalidatePopup = async (revalidateUrl) => {
|
||||
revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url);
|
||||
$(el_window).find('.change-email-btn').addClass('disabled');
|
||||
hint.text(REVALIDATE_POPUP_TEXT).show();
|
||||
try {
|
||||
await openRevalidatePopup(revalidateUrl);
|
||||
} catch (e) {
|
||||
onError(e.message || 'Authentication failed');
|
||||
return;
|
||||
} finally {
|
||||
hint.hide();
|
||||
}
|
||||
};
|
||||
|
||||
$(el_window).find('.change-email-btn').on('click', async function (e) {
|
||||
$(el_window).find('.form-success-msg, .form-error-msg').hide();
|
||||
|
||||
const new_email = $(el_window).find('.new-email').val();
|
||||
const password = password_entry.get('value');
|
||||
const oidc_only = !!(window.user && window.user.oidc_only);
|
||||
|
||||
if ( ! new_email ) {
|
||||
$(el_window).find('.form-error-msg').html(i18n('all_fields_required'));
|
||||
$(el_window).find('.form-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( oidc_only && !revalidated && !password ) {
|
||||
await myOpenRevalidatePopup();
|
||||
|
||||
const res = await doSubmit({ new_email });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if ( res.ok ) onSuccess();
|
||||
else if ( isSessionAuthError(res, data) ) onReauthRequired(data);
|
||||
else onError(data.message || 'Request failed', data);
|
||||
return;
|
||||
}
|
||||
$(el_window).find('.form-error-msg').hide();
|
||||
$(el_window).find('.change-email-btn').addClass('disabled');
|
||||
$(el_window).find('.new-email').attr('disabled', true);
|
||||
|
||||
let res = await doSubmit({ new_email, password });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if ( res.ok ) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
if ( isSessionAuthError(res, data) ) {
|
||||
onReauthRequired(data);
|
||||
return;
|
||||
}
|
||||
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
|
||||
await myOpenRevalidatePopup(data.revalidate_url);
|
||||
const r = await doSubmit({ new_email });
|
||||
if ( r.ok ) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if ( isSessionAuthError(r, d) ) onReauthRequired(d);
|
||||
else onError(d.message || 'Request failed', d);
|
||||
return;
|
||||
}
|
||||
onError(data.message || 'Request failed', data);
|
||||
});
|
||||
|
||||
function doSubmit ({ new_email, password } = {}) {
|
||||
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie).
|
||||
// On a 401 caused by a missing/bad cookie the wrapper mints the cookie
|
||||
// from the GUI bearer token via /session/sync-cookie and retries once.
|
||||
const send = () => fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
new_email,
|
||||
code: $(el_window).find('.change-email-code').val() || undefined,
|
||||
password: password !== undefined && password !== '' ? password : undefined,
|
||||
}),
|
||||
});
|
||||
return fetchWithSessionCookieRetry(send, { origin, authToken: window.auth_token });
|
||||
}
|
||||
|
||||
function onError (message, data) {
|
||||
if ( data?.factor === 'otp' ) {
|
||||
$(el_window).find('.change-email-otp-wrap').show();
|
||||
$(el_window).find('.change-email-code').get(0)?.focus();
|
||||
}
|
||||
$(el_window).find('.form-error-msg').html(html_encode(message));
|
||||
$(el_window).find('.form-error-msg').fadeIn();
|
||||
$(el_window).find('.change-email-btn').removeClass('disabled');
|
||||
$(el_window).find('.new-email').attr('disabled', false);
|
||||
}
|
||||
|
||||
// Session cookie is absent and couldn't be minted from the bearer
|
||||
// token — force the sign-in flow so it gets set, then the user can
|
||||
// retry the change.
|
||||
function onReauthRequired (data) {
|
||||
onError(i18n('reauth_required_message'));
|
||||
window.handleReauthRequired({ reason: data.reason, auth_id: data.auth_id });
|
||||
}
|
||||
|
||||
function onSuccess () {
|
||||
const new_email = $(el_window).find('.new-email').val();
|
||||
$(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent'));
|
||||
$(el_window).find('.form-success-msg').fadeIn();
|
||||
$(el_window).find('input').val('');
|
||||
window.user.email = new_email;
|
||||
$(el_window).find('.change-email-btn').removeClass('disabled');
|
||||
$(el_window).find('.new-email').attr('disabled', false);
|
||||
}
|
||||
}
|
||||
|
||||
export default UIWindowChangeEmail;
|
||||
@@ -1,89 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { 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, password: 'password' }));
|
||||
|
||||
vi.mock('../UIWindow.js', () => ({ default: async options => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = options.body_content;
|
||||
document.body.append(el);
|
||||
options.onAppend?.(el);
|
||||
state.el = el;
|
||||
return el;
|
||||
} }));
|
||||
vi.mock('../Components/PasswordEntry.js', () => ({ default: class {
|
||||
get () { return state.password; }
|
||||
attach () {}
|
||||
} }));
|
||||
vi.mock('../../util/Placeholder.js', () => ({ default: () => ({ html: '' }) }));
|
||||
vi.mock('../../util/openid.js', () => ({ openRevalidatePopup: vi.fn(async () => {}) }));
|
||||
|
||||
const { default: UIWindowChangeEmail } = await import('./UIWindowChangeEmail.js');
|
||||
const { openRevalidatePopup } = await import('../../util/openid.js');
|
||||
const reply = (status, data = {}) => new Response(JSON.stringify(data), { status });
|
||||
const submit = () => jQuery(state.el).find('.change-email-btn').triggerHandler('click');
|
||||
const bodyOf = index => JSON.parse(fetch.mock.calls[index][1].body);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.i18n = key => key;
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.fetch = vi.fn();
|
||||
window.uuidv4 = () => 'test-id';
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.auth_token = 'session-token';
|
||||
window.handleReauthRequired = vi.fn();
|
||||
state.password = 'password';
|
||||
openRevalidatePopup.mockClear();
|
||||
});
|
||||
|
||||
const open = async user => {
|
||||
window.user = user;
|
||||
await UIWindowChangeEmail();
|
||||
jQuery(state.el).find('.new-email').val('[email protected]');
|
||||
};
|
||||
|
||||
describe('email change authenticator input', () => {
|
||||
it('shows the enabled account code field and preserves leading zeroes', async () => {
|
||||
await open({ otp: true });
|
||||
expect(jQuery(state.el).find('.change-email-otp-wrap')[0].style.display).not.toBe('none');
|
||||
jQuery(state.el).find('.change-email-code').val('000123');
|
||||
fetch.mockResolvedValue(reply(200));
|
||||
await submit();
|
||||
expect(bodyOf(0)).toEqual({ new_email: '[email protected]', password: 'password', code: '000123' });
|
||||
});
|
||||
|
||||
it('keeps the code field hidden for accounts without 2FA', async () => {
|
||||
await open({ otp: false });
|
||||
expect(jQuery(state.el).find('.change-email-otp-wrap')[0].style.display).toBe('none');
|
||||
fetch.mockResolvedValue(reply(200));
|
||||
await submit();
|
||||
expect(bodyOf(0)).not.toHaveProperty('code');
|
||||
});
|
||||
|
||||
it('reveals the field when the server reports a second-factor requirement', async () => {
|
||||
await open({ otp: false });
|
||||
fetch.mockResolvedValueOnce(reply(400, { factor: 'otp', code: 'bad_request', message: 'Enter your code' }));
|
||||
await submit();
|
||||
expect(jQuery(state.el).find('.change-email-otp-wrap')[0].style.display).not.toBe('none');
|
||||
expect(jQuery(state.el).find('.new-email').prop('disabled')).toBe(false);
|
||||
jQuery(state.el).find('.change-email-code').val('000123');
|
||||
fetch.mockResolvedValueOnce(reply(401, { factor: 'otp', code: 'code_mismatch', message: 'Incorrect code' }));
|
||||
await submit();
|
||||
expect(window.handleReauthRequired).not.toHaveBeenCalled();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([true, false])('includes the code after OIDC revalidation (cached OIDC-only state: %s)', async oidcOnly => {
|
||||
await open({ otp: true, oidc_only: oidcOnly, oidc_revalidate_url: 'https://puter.test/revalidate' });
|
||||
jQuery(state.el).find('.change-email-code').val('000123');
|
||||
if (oidcOnly) state.password = '';
|
||||
else fetch.mockResolvedValueOnce(reply(403, { code: 'oidc_revalidation_required', revalidate_url: 'https://puter.test/revalidate' }));
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
await submit();
|
||||
expect(openRevalidatePopup).toHaveBeenCalledOnce();
|
||||
expect(bodyOf(oidcOnly ? 0 : 1)).toEqual({ new_email: '[email protected]', code: '000123' });
|
||||
});
|
||||
});
|
||||
@@ -1,84 +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 UIWindowFinalizeUserDeletion from './UIWindowFinalizeUserDeletion.js';
|
||||
|
||||
async function UIWindowConfirmUserDeletion (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
options = options ?? {};
|
||||
|
||||
let h = '';
|
||||
h += '<div style="padding: 20px;">';
|
||||
h += '<div class="generic-close-window-button disable-user-select"> × </div>';
|
||||
h += `<img src="${window.icons['danger.svg']}" class="account-deletion-confirmation-icon">`;
|
||||
h += `<p class="account-deletion-confirmation-prompt">${i18n('confirm_delete_user')}</p>`;
|
||||
h += `<button class="button button-block button-danger proceed-with-user-deletion">${i18n('proceed_with_account_deletion')}</button>`;
|
||||
h += `<button class="button button-block button-secondary cancel-user-deletion">${i18n('cancel')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: i18n('confirm_delete_user_title'),
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: false,
|
||||
selectable_body: false,
|
||||
draggable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_draggable: true,
|
||||
is_resizable: false,
|
||||
is_droppable: false,
|
||||
init_center: true,
|
||||
allow_native_ctxmenu: true,
|
||||
allow_user_select: true,
|
||||
backdrop: true,
|
||||
onAppend: function (el_window) {
|
||||
},
|
||||
width: 500,
|
||||
dominant: true,
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
boxShadow: '0 0 10px rgba(0,0,0,.2)',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: 'white',
|
||||
color: 'black',
|
||||
},
|
||||
...options.window_options,
|
||||
});
|
||||
|
||||
$(el_window).find('.generic-close-window-button').on('click', function () {
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
$(el_window).find('.cancel-user-deletion').on('click', function () {
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
$(el_window).find('.proceed-with-user-deletion').on('click', function () {
|
||||
UIWindowFinalizeUserDeletion();
|
||||
$(el_window).close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowConfirmUserDeletion;
|
||||
@@ -1,262 +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 { openRevalidatePopup } from '../../util/openid.js';
|
||||
import { fetchWithSessionCookieRetry, isSessionAuthError } from '../../util/sessionAuth.js';
|
||||
import UIWindow from '../UIWindow.js';
|
||||
|
||||
async function UIWindowFinalizeUserDeletion (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
options = options ?? {};
|
||||
const oidc_only = !!(window.user && window.user.oidc_only);
|
||||
|
||||
let h = '';
|
||||
|
||||
// if user is temporary, ask them to type in 'confirm' to delete their account
|
||||
if ( window.user.is_temp ) {
|
||||
h += '<div style="padding: 20px;">';
|
||||
h += '<div class="generic-close-window-button disable-user-select"> × </div>';
|
||||
h += `<img src="${window.icons['danger.svg']}" class="account-deletion-confirmation-icon">`;
|
||||
h += `<p class="account-deletion-confirmation-prompt">${i18n('type_confirm_to_delete_account')}</p>`;
|
||||
// error message
|
||||
h += '<div class="error-message"></div>';
|
||||
// input field
|
||||
h += `<input type="text" class="confirm-temporary-user-deletion" placeholder="${i18n('type_confirm_to_delete_account')}">`;
|
||||
h += `<button class="button button-block button-danger proceed-with-user-deletion">${i18n('delete_account')}</button>`;
|
||||
h += `<button class="button button-block button-secondary cancel-user-deletion">${i18n('cancel')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
// OIDC-only: revalidate via popup (no password)
|
||||
else if ( oidc_only ) {
|
||||
h += '<div style="padding: 20px;">';
|
||||
h += '<div class="generic-close-window-button disable-user-select"> × </div>';
|
||||
h += `<img src="${window.icons['danger.svg']}" class="account-deletion-confirmation-icon">`;
|
||||
h += `<p class="account-deletion-confirmation-prompt">${i18n('confirm_delete_user')}</p>`;
|
||||
h += '<div class="delete-oidc-wrap" style="margin-top:10px;">';
|
||||
h += '<p class="delete-oidc-flow-notice" style="margin:0;font-size:12px;color:#666;"></p>';
|
||||
h += '<span class="delete-revalidated-msg" style="display:none;"></span>';
|
||||
h += '</div>';
|
||||
h += '<p class="delete-oidc-hint" style="margin-top:6px;font-size:12px;color:#666;display:none;"></p>';
|
||||
h += '<div class="error-message"></div>';
|
||||
h += `<button class="button button-block button-danger proceed-with-user-deletion">${i18n('delete_account')}</button>`;
|
||||
h += `<button class="button button-block button-secondary cancel-user-deletion">${i18n('cancel')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
// otherwise ask for password
|
||||
else {
|
||||
h += '<div style="padding: 20px;">';
|
||||
h += '<div class="generic-close-window-button disable-user-select"> × </div>';
|
||||
h += `<img src="${window.icons['danger.svg']}" class="account-deletion-confirmation-icon">`;
|
||||
h += `<p class="account-deletion-confirmation-prompt">${i18n('enter_password_to_confirm_delete_user')}</p>`;
|
||||
// error message
|
||||
h += '<div class="error-message"></div>';
|
||||
// input field
|
||||
h += `<input type="password" class="confirm-user-deletion-password" placeholder="${i18n('current_password')}">`;
|
||||
h += `<button class="button button-block button-danger proceed-with-user-deletion">${i18n('delete_account')}</button>`;
|
||||
h += `<button class="button button-block button-secondary cancel-user-deletion">${i18n('cancel')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: i18n('confirm_delete_user_title'),
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
body_content: h,
|
||||
has_head: false,
|
||||
selectable_body: false,
|
||||
draggable_body: false,
|
||||
allow_context_menu: false,
|
||||
is_draggable: true,
|
||||
is_resizable: false,
|
||||
is_droppable: false,
|
||||
init_center: true,
|
||||
allow_native_ctxmenu: true,
|
||||
allow_user_select: true,
|
||||
backdrop: true,
|
||||
onAppend: function (el_window) {
|
||||
if ( oidc_only ) {
|
||||
$(el_window).find('.delete-oidc-flow-notice').text(
|
||||
i18n('revalidate_flow_notice') ||
|
||||
'You will be asked to sign in with your linked account when you continue.',
|
||||
);
|
||||
}
|
||||
},
|
||||
width: 500,
|
||||
dominant: false,
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
boxShadow: '0 0 10px rgba(0,0,0,.2)',
|
||||
},
|
||||
});
|
||||
|
||||
$(el_window).find('.generic-close-window-button').on('click', function () {
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
$(el_window).find('.cancel-user-deletion').on('click', function () {
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
const origin = window.gui_origin || window.api_origin || '';
|
||||
const apiUrl = `${origin}/user-protected/delete-own-user`;
|
||||
const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.';
|
||||
let revalidated = false;
|
||||
|
||||
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie).
|
||||
// On a 401 caused by a missing/bad cookie the wrapper mints the cookie
|
||||
// from the GUI bearer token via /session/sync-cookie and retries once.
|
||||
const doDeleteRequest = async (body = {}) => {
|
||||
const send = () => fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return fetchWithSessionCookieRetry(send, { origin, authToken: window.auth_token });
|
||||
};
|
||||
|
||||
const showError = (msg) => {
|
||||
$(el_window).find('.error-message').html(html_encode(msg)).show();
|
||||
};
|
||||
|
||||
// Session cookie is absent and couldn't be minted from the bearer
|
||||
// token. Don't hard-logout — that drops the window and looks like the
|
||||
// deletion went through. Prompt sign-in so the cookie gets set and the
|
||||
// user can decide again.
|
||||
const onReauthRequired = (data) => {
|
||||
showError(i18n('reauth_required_message'));
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
window.handleReauthRequired({ reason: data.reason, auth_id: data.auth_id });
|
||||
};
|
||||
|
||||
$(el_window).find('.proceed-with-user-deletion').on('click', async function () {
|
||||
$(el_window).find('.error-message').hide();
|
||||
// if user is temporary, check if they typed 'confirm'
|
||||
if ( window.user.is_temp ) {
|
||||
const confirm = $(el_window).find('.confirm-temporary-user-deletion').val().toLowerCase();
|
||||
|
||||
// user must type 'confirm' or the translation of 'confirm' to delete their account
|
||||
if ( confirm !== 'confirm' && confirm !== i18n('confirm').toLowerCase() ) {
|
||||
showError(i18n('type_confirm_to_delete_account', [], false));
|
||||
return;
|
||||
}
|
||||
} else if ( oidc_only && !revalidated ) {
|
||||
$(el_window).find('.proceed-with-user-deletion').addClass('disabled');
|
||||
$(el_window).find('.delete-oidc-hint').text(REVALIDATE_POPUP_TEXT).show();
|
||||
try {
|
||||
const revalidateUrl = window.user && window.user.oidc_revalidate_url;
|
||||
await openRevalidatePopup(revalidateUrl);
|
||||
} catch (e) {
|
||||
showError(e.message || 'Authentication failed');
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
$(el_window).find('.delete-oidc-hint').hide();
|
||||
return;
|
||||
}
|
||||
$(el_window).find('.delete-oidc-hint').hide();
|
||||
$(el_window).find('.delete-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show();
|
||||
revalidated = true;
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
const res = await doDeleteRequest({});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if ( isSessionAuthError(res, data) ) {
|
||||
onReauthRequired(data); return;
|
||||
}
|
||||
if ( res.ok && data.success ) {
|
||||
window.user.deleted = true; window.logout(); return;
|
||||
}
|
||||
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
|
||||
try {
|
||||
await openRevalidatePopup(data.revalidate_url);
|
||||
} catch (e) {
|
||||
showError(e.message || 'Authentication failed');
|
||||
return;
|
||||
}
|
||||
const retry = await doDeleteRequest({});
|
||||
const retryData = await retry.json().catch(() => ({}));
|
||||
if ( retry.ok && retryData.success ) {
|
||||
window.user.deleted = true; window.logout(); return;
|
||||
}
|
||||
if ( isSessionAuthError(retry, retryData) ) {
|
||||
onReauthRequired(retryData); return;
|
||||
}
|
||||
showError(retryData.message || 'Request failed');
|
||||
return;
|
||||
}
|
||||
showError(data.message || 'Request failed');
|
||||
return;
|
||||
} else if ( !window.user.is_temp && !oidc_only ) {
|
||||
const password = $(el_window).find('.confirm-user-deletion-password').val();
|
||||
if ( password === '' ) {
|
||||
showError(i18n('all_fields_required', [], false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let res = await doDeleteRequest(
|
||||
window.user.is_temp ? {} : { password: $(el_window).find('.confirm-user-deletion-password').val() || undefined },
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if ( isSessionAuthError(res, data) ) {
|
||||
onReauthRequired(data);
|
||||
return;
|
||||
}
|
||||
if ( res.ok && data.success ) {
|
||||
window.user.deleted = true;
|
||||
window.logout();
|
||||
return;
|
||||
}
|
||||
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
|
||||
$(el_window).find('.proceed-with-user-deletion').addClass('disabled');
|
||||
$(el_window).find('.delete-oidc-hint').text(REVALIDATE_POPUP_TEXT).show();
|
||||
try {
|
||||
await openRevalidatePopup(data.revalidate_url);
|
||||
} catch (e) {
|
||||
showError(e.message || 'Authentication failed');
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
$(el_window).find('.delete-oidc-hint').hide();
|
||||
return;
|
||||
}
|
||||
$(el_window).find('.delete-oidc-hint').hide();
|
||||
$(el_window).find('.proceed-with-user-deletion').removeClass('disabled');
|
||||
const retry = await doDeleteRequest({});
|
||||
const retryData = await retry.json().catch(() => ({}));
|
||||
if ( retry.ok && retryData.success ) {
|
||||
window.user.deleted = true;
|
||||
window.logout();
|
||||
return;
|
||||
}
|
||||
if ( isSessionAuthError(retry, retryData) ) {
|
||||
onReauthRequired(retryData);
|
||||
return;
|
||||
}
|
||||
showError(retryData.message || 'Request failed');
|
||||
return;
|
||||
}
|
||||
showError(data.message || 'Request failed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowFinalizeUserDeletion;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const state = vi.hoisted(() => ({ el: null }));
|
||||
|
||||
vi.mock('../UIWindow.js', () => ({
|
||||
default: vi.fn(async (opts) => {
|
||||
state.el = { body: opts.body_content, opts };
|
||||
return state.el;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../util/openid.js', () => ({
|
||||
openRevalidatePopup: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
|
||||
const fields = {};
|
||||
let clickHandler = null;
|
||||
const node = (sel) => {
|
||||
const self = {
|
||||
val: () => fields[sel],
|
||||
on: (evt, fn) => {
|
||||
if ( evt === 'click' && sel === '.proceed-with-user-deletion' ) clickHandler = fn;
|
||||
return self;
|
||||
},
|
||||
html: () => self,
|
||||
text: () => self,
|
||||
show: () => self,
|
||||
hide: () => self,
|
||||
fadeIn: () => self,
|
||||
addClass: () => self,
|
||||
removeClass: () => self,
|
||||
attr: () => self,
|
||||
};
|
||||
return self;
|
||||
};
|
||||
globalThis.$ = () => ({ find: node, close: () => {} });
|
||||
|
||||
const { default: UIWindowFinalizeUserDeletion } = await import('./UIWindowFinalizeUserDeletion.js');
|
||||
|
||||
const rejected401 = (code) => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
clone: () => ({ json: async () => ({ code }) }),
|
||||
json: async () => ({ code }),
|
||||
});
|
||||
|
||||
const open = async () => {
|
||||
UIWindowFinalizeUserDeletion({});
|
||||
await new Promise((r) => setTimeout(r));
|
||||
};
|
||||
|
||||
describe('the account-deletion confirmation', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn();
|
||||
globalThis.window = {
|
||||
icons: { 'danger.svg': 'danger.svg' },
|
||||
user: { oidc_only: true, is_temp: false },
|
||||
gui_origin: 'https://puter.test',
|
||||
auth_token: 'tok-1',
|
||||
handleReauthRequired: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
clickHandler = null;
|
||||
});
|
||||
|
||||
it('mints the session cookie and retries when the delete 401s', async () => {
|
||||
globalThis.fetch
|
||||
.mockResolvedValueOnce(rejected401('token_missing'))
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }) });
|
||||
|
||||
await open();
|
||||
await clickHandler();
|
||||
|
||||
const urls = globalThis.fetch.mock.calls.map(([u]) => String(u));
|
||||
expect(urls[1]).toContain('/session/sync-cookie');
|
||||
expect(globalThis.window.user.deleted).toBe(true);
|
||||
});
|
||||
|
||||
// A hard logout here drops the window and reads as "the account is gone".
|
||||
it('prompts sign-in rather than logging out when the cookie cannot be minted', async () => {
|
||||
globalThis.fetch
|
||||
.mockResolvedValueOnce(rejected401('token_missing'))
|
||||
.mockResolvedValueOnce({ ok: false, json: async () => ({}) });
|
||||
|
||||
await open();
|
||||
await clickHandler();
|
||||
|
||||
expect(globalThis.window.handleReauthRequired).toHaveBeenCalled();
|
||||
expect(globalThis.window.logout).not.toHaveBeenCalled();
|
||||
expect(globalThis.window.user.deleted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,237 +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 { openRevalidatePopup } from '../util/openid.js';
|
||||
import { fetchWithSessionCookieRetry, isSessionAuthError } from '../util/sessionAuth.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
|
||||
async function UIWindowChangePassword (options) {
|
||||
options = options ?? {};
|
||||
|
||||
const internal_id = window.uuidv4();
|
||||
let h = '';
|
||||
h += '<div class="change-password" style="padding: 20px; border-bottom: 1px solid #ced7e1;">';
|
||||
// error msg
|
||||
h += '<div class="form-error-msg"></div>';
|
||||
// success msg
|
||||
h += '<div class="form-success-msg"></div>';
|
||||
// current password / OIDC revalidate
|
||||
h += '<div class="change-password-auth-row" style="overflow: hidden; margin-bottom: 20px;">';
|
||||
h += '<div class="change-password-current-wrap">';
|
||||
h += `<label for="current-password-${internal_id}">${i18n('current_password')}</label>`;
|
||||
h += `<input id="current-password-${internal_id}" class="current-password" type="password" name="current-password" autocomplete="current-password" />`;
|
||||
h += '</div>';
|
||||
h += '<div class="change-password-oidc-wrap" style="display:none;">';
|
||||
h += '<p class="change-password-oidc-flow-notice" style="margin:0;font-size:12px;color:#666;"></p>';
|
||||
h += '<span class="change-password-revalidated-msg" style="display:none;"></span>';
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
// new password
|
||||
h += '<div style="overflow: hidden; margin-top: 20px; margin-bottom: 20px;">';
|
||||
h += `<label for="new-password-${internal_id}">${i18n('new_password')}</label>`;
|
||||
h += `<input id="new-password-${internal_id}" type="password" class="new-password" name="new-password" autocomplete="off" />`;
|
||||
h += '</div>';
|
||||
// confirm new password
|
||||
h += '<div style="overflow: hidden; margin-top: 20px; margin-bottom: 20px;">';
|
||||
h += `<label for="confirm-new-password-${internal_id}">${i18n('confirm_new_password')}</label>`;
|
||||
h += `<input id="confirm-new-password-${internal_id}" type="password" name="confirm-new-password" class="confirm-new-password" autocomplete="off" />`;
|
||||
h += '</div>';
|
||||
h += '<p class="change-password-oidc-hint" style="margin-top:6px;font-size:12px;color:#666;display:none;"></p>';
|
||||
|
||||
// Change Password
|
||||
h += `<button class="change-password-btn button button-primary button-block button-normal">${i18n('change_password')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: i18n('window_title_change_password'),
|
||||
app: 'change-passowrd',
|
||||
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_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,
|
||||
onAppend: function (this_window) {
|
||||
$(this_window).find('.current-password').get(0)?.focus({ preventScroll: true });
|
||||
const oidc_only = !!(window.user && window.user.oidc_only);
|
||||
const authRow = $(this_window).find('.change-password-auth-row');
|
||||
if ( oidc_only ) {
|
||||
authRow.find('.change-password-current-wrap').hide();
|
||||
// OIDC: no notice box; user will see revalidation when they continue
|
||||
} else {
|
||||
authRow.find('.change-password-oidc-wrap').hide();
|
||||
}
|
||||
},
|
||||
window_class: 'window-publishWebsite',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': 'rgb(245 247 249)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
},
|
||||
...options.window_options,
|
||||
});
|
||||
|
||||
const origin = window.gui_origin || window.api_origin || '';
|
||||
const apiUrl = `${origin}/user-protected/change-password`;
|
||||
let revalidated = false;
|
||||
|
||||
const hint = $(el_window).find('.change-password-oidc-hint');
|
||||
const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.';
|
||||
|
||||
const myOpenRevalidatePopup = async (revalidateUrl) => {
|
||||
revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url);
|
||||
$(el_window).find('.change-password-btn').addClass('disabled');
|
||||
hint.text(REVALIDATE_POPUP_TEXT).show();
|
||||
try {
|
||||
await openRevalidatePopup(revalidateUrl);
|
||||
} catch (e) {
|
||||
onError(e.message || 'Authentication failed');
|
||||
return;
|
||||
} finally {
|
||||
hint.hide();
|
||||
}
|
||||
};
|
||||
|
||||
$(el_window).find('.change-password-btn').on('click', async function (e) {
|
||||
const current_password = $(el_window).find('.current-password').val();
|
||||
const new_password = $(el_window).find('.new-password').val();
|
||||
const confirm_new_password = $(el_window).find('.confirm-new-password').val();
|
||||
const oidc_only = !!(window.user && window.user.oidc_only);
|
||||
|
||||
$(el_window).find('.form-success-msg, .form-error-msg').hide();
|
||||
|
||||
if ( !new_password || !confirm_new_password ) {
|
||||
$(el_window).find('.form-error-msg').html('All fields are required.');
|
||||
$(el_window).find('.form-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
// For password users, current password is required; for OIDC, we need revalidated or will open popup
|
||||
if ( !oidc_only && !current_password ) {
|
||||
$(el_window).find('.form-error-msg').html('All fields are required.');
|
||||
$(el_window).find('.form-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
if ( new_password !== confirm_new_password ) {
|
||||
$(el_window).find('.form-error-msg').html(i18n('passwords_do_not_match'));
|
||||
$(el_window).find('.form-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
const pass_strength = check_password_strength(new_password);
|
||||
if ( ! pass_strength.overallPass ) {
|
||||
$(el_window).find('.form-error-msg').html(i18n('password_strength_error'));
|
||||
$(el_window).find('.form-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( oidc_only && !revalidated && !current_password ) {
|
||||
await myOpenRevalidatePopup();
|
||||
|
||||
const res = await doSubmit({ new_password });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if ( res.ok ) onSuccess();
|
||||
else if ( isSessionAuthError(res, data) ) onReauthRequired(data);
|
||||
else onError(data.message || 'Request failed');
|
||||
return;
|
||||
}
|
||||
|
||||
$(el_window).find('.form-error-msg').hide();
|
||||
$(el_window).find('.change-password-btn').addClass('disabled');
|
||||
$(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', true);
|
||||
|
||||
let res = await doSubmit({ current_password, new_password });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if ( res.ok ) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
if ( isSessionAuthError(res, data) ) {
|
||||
onReauthRequired(data);
|
||||
return;
|
||||
}
|
||||
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
|
||||
await myOpenRevalidatePopup(data.revalidate_url);
|
||||
const r = await doSubmit({ new_password });
|
||||
if ( r.ok ) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if ( isSessionAuthError(r, d) ) onReauthRequired(d);
|
||||
else onError(d.message || 'Request failed');
|
||||
return;
|
||||
}
|
||||
onError(data.message || res.statusText || 'Request failed');
|
||||
});
|
||||
|
||||
function doSubmit ({ new_password, current_password } = {}) {
|
||||
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie).
|
||||
// On a 401 caused by a missing/bad cookie the wrapper mints the cookie
|
||||
// from the GUI bearer token via /session/sync-cookie and retries once.
|
||||
const send = () => fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
password: current_password,
|
||||
new_pass: new_password,
|
||||
}),
|
||||
});
|
||||
return fetchWithSessionCookieRetry(send, { origin, authToken: window.auth_token });
|
||||
}
|
||||
|
||||
function onError (message) {
|
||||
$(el_window).find('.form-error-msg').html(html_encode(message));
|
||||
$(el_window).find('.form-error-msg').fadeIn();
|
||||
$(el_window).find('.change-password-btn').removeClass('disabled');
|
||||
$(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', false);
|
||||
}
|
||||
|
||||
// Session cookie is absent and couldn't be minted from the bearer
|
||||
// token — force the sign-in flow so it gets set, then the user can
|
||||
// retry the change.
|
||||
function onReauthRequired (data) {
|
||||
onError(i18n('reauth_required_message'));
|
||||
window.handleReauthRequired({ reason: data.reason, auth_id: data.auth_id });
|
||||
}
|
||||
|
||||
function onSuccess () {
|
||||
$(el_window).find('.form-success-msg').html(i18n('password_changed'));
|
||||
$(el_window).find('.form-success-msg').fadeIn();
|
||||
$(el_window).find('input').val('');
|
||||
$(el_window).find('.change-password-btn').removeClass('disabled');
|
||||
$(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', false);
|
||||
}
|
||||
}
|
||||
|
||||
export default UIWindowChangePassword;
|
||||
@@ -1,132 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const state = vi.hoisted(() => ({ el: null }));
|
||||
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: vi.fn(async (opts) => {
|
||||
state.el = { body: opts.body_content, opts };
|
||||
return state.el;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../helpers/checkPasswordStrength.js', () => ({
|
||||
default: vi.fn((pw) => ({ overallPass: pw !== 'weak' })),
|
||||
}));
|
||||
|
||||
vi.mock('../util/openid.js', () => ({
|
||||
openRevalidatePopup: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
|
||||
/** Minimal jQuery stand-in: a selector -> value map drives the handler. */
|
||||
const fields = {};
|
||||
let clickHandler = null;
|
||||
const node = (sel) => {
|
||||
const self = {
|
||||
val: (v) => {
|
||||
if ( v === undefined ) return fields[sel];
|
||||
fields[sel] = v;
|
||||
return self;
|
||||
},
|
||||
on: (evt, fn) => {
|
||||
if ( evt === 'click' && sel === '.change-password-btn' ) clickHandler = fn;
|
||||
return self;
|
||||
},
|
||||
html: () => self,
|
||||
text: () => self,
|
||||
show: () => self,
|
||||
hide: () => self,
|
||||
fadeIn: () => self,
|
||||
addClass: () => self,
|
||||
removeClass: () => self,
|
||||
attr: () => self,
|
||||
get: () => undefined,
|
||||
};
|
||||
return self;
|
||||
};
|
||||
globalThis.$ = () => ({ find: node, close: () => {} });
|
||||
|
||||
const { default: UIWindowChangePassword } = await import('./UIWindowChangePassword.js');
|
||||
|
||||
const open = async (user) => {
|
||||
globalThis.window.user = user;
|
||||
UIWindowChangePassword({});
|
||||
await new Promise((r) => setTimeout(r));
|
||||
};
|
||||
|
||||
const rejected401 = (code) => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
clone: () => ({ json: async () => ({ code, message: 'Missing authentication token' }) }),
|
||||
json: async () => ({ code, message: 'Missing authentication token' }),
|
||||
});
|
||||
|
||||
const bodyOf = (call) => JSON.parse(call[1].body);
|
||||
|
||||
describe('the change-password window', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn();
|
||||
globalThis.window = {
|
||||
uuidv4: () => 'test-id',
|
||||
gui_origin: 'https://puter.test',
|
||||
auth_token: 'tok-1',
|
||||
handleReauthRequired: vi.fn(),
|
||||
};
|
||||
clickHandler = null;
|
||||
fields['.current-password'] = '';
|
||||
fields['.new-password'] = 'ChosenPass1!';
|
||||
fields['.confirm-new-password'] = 'ChosenPass1!';
|
||||
});
|
||||
|
||||
// The route is cookie-gated and the GUI sends no Authorization header, so
|
||||
// a session cookie that went missing mid-session reads as a plain 401.
|
||||
it('mints the session cookie and retries when the submit 401s', async () => {
|
||||
globalThis.fetch
|
||||
.mockResolvedValueOnce(rejected401('token_missing'))
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
|
||||
|
||||
await open({ oidc_only: true, oidc_revalidate_url: 'https://puter.test/revalidate' });
|
||||
await clickHandler();
|
||||
|
||||
const urls = globalThis.fetch.mock.calls.map(([u]) => String(u));
|
||||
expect(urls[1]).toContain('/session/sync-cookie');
|
||||
expect(urls[2]).toContain('/user-protected/change-password');
|
||||
expect(globalThis.window.handleReauthRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts sign-in when the cookie cannot be minted', async () => {
|
||||
globalThis.fetch
|
||||
.mockResolvedValueOnce(rejected401('token_missing'))
|
||||
.mockResolvedValueOnce({ ok: false, json: async () => ({}) });
|
||||
|
||||
await open({ oidc_only: true, oidc_revalidate_url: 'https://puter.test/revalidate' });
|
||||
await clickHandler();
|
||||
|
||||
expect(globalThis.window.handleReauthRequired).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// `oidc_only` on the cached user row can lag the account's real state, so
|
||||
// the server-driven revalidation retry has to carry the new password too.
|
||||
it('resends the new password on the revalidation retry', async () => {
|
||||
fields['.current-password'] = 'OldPass1!';
|
||||
globalThis.fetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({
|
||||
code: 'oidc_revalidation_required',
|
||||
revalidate_url: 'https://puter.test/revalidate',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
|
||||
|
||||
await open({ oidc_only: false });
|
||||
await clickHandler();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(bodyOf(globalThis.fetch.mock.calls[1])).toEqual({ new_pass: 'ChosenPass1!' });
|
||||
});
|
||||
});
|
||||
@@ -1,116 +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';
|
||||
|
||||
// Keep in sync with ProfileService on the backend.
|
||||
const DISPLAY_NAME_MAX_LENGTH = 64;
|
||||
const BIO_MAX_LENGTH = 280;
|
||||
|
||||
/**
|
||||
* Edits the signed-in user's display name and bio. `options.onSave` receives
|
||||
* the saved profile.
|
||||
*/
|
||||
async function UIWindowEditProfile (options) {
|
||||
options = options ?? {};
|
||||
|
||||
const internal_id = window.uuidv4();
|
||||
let h = '';
|
||||
h += '<div class="edit-profile" style="padding: 20px; border-bottom: 1px solid #ced7e1;">';
|
||||
h += '<div class="form-error-msg"></div>';
|
||||
h += '<div class="form-success-msg"></div>';
|
||||
h += '<div style="overflow: hidden; margin-bottom: 20px;">';
|
||||
h += `<label for="edit-profile-display-name-${internal_id}">${i18n('display_name')}</label>`;
|
||||
h += `<input id="edit-profile-display-name-${internal_id}" type="text" class="edit-profile-display-name" maxlength="${DISPLAY_NAME_MAX_LENGTH}" autocomplete="off" />`;
|
||||
h += '</div>';
|
||||
h += '<div style="overflow: hidden; margin-bottom: 20px;">';
|
||||
h += `<label for="edit-profile-bio-${internal_id}">${i18n('bio')}</label>`;
|
||||
h += `<textarea id="edit-profile-bio-${internal_id}" class="edit-profile-bio" maxlength="${BIO_MAX_LENGTH}" rows="4" style="display: block; width: 100%; padding: 8px; box-sizing: border-box; resize: vertical; border: 1px solid #ccc; border-radius: 4px; font: inherit; font-size: 14px; color: #393f46;"></textarea>`;
|
||||
h += '</div>';
|
||||
h += `<button class="edit-profile-btn button button-primary button-block button-normal">${i18n('save')}</button>`;
|
||||
h += '</div>';
|
||||
|
||||
const el_window = await UIWindow({
|
||||
title: i18n('edit_profile'),
|
||||
app: 'edit-profile',
|
||||
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_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,
|
||||
onAppend: function (this_window) {
|
||||
$(this_window).find('.edit-profile-display-name').get(0)?.focus({ preventScroll: true });
|
||||
},
|
||||
window_class: 'window-publishWebsite',
|
||||
body_css: {
|
||||
width: 'initial',
|
||||
height: '100%',
|
||||
'background-color': 'rgb(245 247 249)',
|
||||
'backdrop-filter': 'blur(3px)',
|
||||
},
|
||||
...options.window_options,
|
||||
});
|
||||
|
||||
const $fields = $(el_window).find('.edit-profile-display-name, .edit-profile-bio');
|
||||
$(el_window).find('.edit-profile-display-name').val(window.user?.profile?.displayName ?? '');
|
||||
$(el_window).find('.edit-profile-bio').val(window.user?.profile?.bio ?? '');
|
||||
|
||||
$(el_window).find('.edit-profile-btn').on('click', async function () {
|
||||
const $btn = $(el_window).find('.edit-profile-btn');
|
||||
$(el_window).find('.form-success-msg, .form-error-msg').hide();
|
||||
$btn.addClass('disabled');
|
||||
$fields.attr('disabled', true);
|
||||
|
||||
// An empty field clears it.
|
||||
const patch = {
|
||||
displayName: String($(el_window).find('.edit-profile-display-name').val() ?? '').trim() || null,
|
||||
bio: String($(el_window).find('.edit-profile-bio').val() ?? '').trim() || null,
|
||||
};
|
||||
// update_profile resolves with no profile when the request failed.
|
||||
const profile = await window.update_profile(window.user.username, patch);
|
||||
|
||||
$btn.removeClass('disabled');
|
||||
$fields.attr('disabled', false);
|
||||
if ( ! profile ) {
|
||||
$(el_window).find('.form-error-msg').html(i18n('profile_save_failed'));
|
||||
$(el_window).find('.form-error-msg').fadeIn();
|
||||
return;
|
||||
}
|
||||
$(el_window).find('.edit-profile-display-name').val(profile.displayName ?? '');
|
||||
$(el_window).find('.edit-profile-bio').val(profile.bio ?? '');
|
||||
$(el_window).find('.form-success-msg').html(i18n('profile_saved'));
|
||||
$(el_window).find('.form-success-msg').fadeIn();
|
||||
options.onSave?.(profile);
|
||||
});
|
||||
}
|
||||
|
||||
export default UIWindowEditProfile;
|
||||
@@ -1,87 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
|
||||
/** Minimal jQuery stand-in: a selector -> value map drives the handler. */
|
||||
const fields = {};
|
||||
const shown = {};
|
||||
let clickHandler = null;
|
||||
const node = (sel) => {
|
||||
const self = {
|
||||
val: (v) => {
|
||||
if ( v === undefined ) return fields[sel];
|
||||
fields[sel] = v;
|
||||
return self;
|
||||
},
|
||||
on: (evt, fn) => {
|
||||
if ( evt === 'click' && sel === '.edit-profile-btn' ) clickHandler = fn;
|
||||
return self;
|
||||
},
|
||||
html: (v) => {
|
||||
shown[sel] = v;
|
||||
return self;
|
||||
},
|
||||
show: () => self,
|
||||
hide: () => self,
|
||||
fadeIn: () => self,
|
||||
addClass: () => self,
|
||||
removeClass: () => self,
|
||||
attr: () => self,
|
||||
get: () => undefined,
|
||||
};
|
||||
return self;
|
||||
};
|
||||
globalThis.$ = () => ({ find: node });
|
||||
|
||||
const { default: UIWindowEditProfile } = await import('./UIWindowEditProfile.js');
|
||||
|
||||
describe('the edit-profile window', () => {
|
||||
beforeEach(() => {
|
||||
for ( const key of Object.keys(fields) ) delete fields[key];
|
||||
for ( const key of Object.keys(shown) ) delete shown[key];
|
||||
clickHandler = null;
|
||||
globalThis.window = {
|
||||
uuidv4: () => 'test-id',
|
||||
user: { username: 'alice', profile: { displayName: 'Alice', bio: 'Hi there' } },
|
||||
update_profile: vi.fn(async (_username, patch) => patch),
|
||||
};
|
||||
});
|
||||
|
||||
it('prefills the current display name and bio', async () => {
|
||||
await UIWindowEditProfile({});
|
||||
|
||||
expect(fields['.edit-profile-display-name']).toBe('Alice');
|
||||
expect(fields['.edit-profile-bio']).toBe('Hi there');
|
||||
});
|
||||
|
||||
it('saves trimmed values and clears empty fields', async () => {
|
||||
const onSave = vi.fn();
|
||||
await UIWindowEditProfile({ onSave });
|
||||
fields['.edit-profile-display-name'] = ' Alice Smith ';
|
||||
fields['.edit-profile-bio'] = ' ';
|
||||
|
||||
await clickHandler();
|
||||
|
||||
expect(window.update_profile).toHaveBeenCalledWith('alice', {
|
||||
displayName: 'Alice Smith',
|
||||
bio: null,
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledWith({ displayName: 'Alice Smith', bio: null });
|
||||
expect(shown['.form-success-msg']).toBe('profile_saved');
|
||||
});
|
||||
|
||||
it('shows an error and skips onSave when the save fails', async () => {
|
||||
window.update_profile = vi.fn(async () => undefined);
|
||||
const onSave = vi.fn();
|
||||
await UIWindowEditProfile({ onSave });
|
||||
|
||||
await clickHandler();
|
||||
|
||||
expect(shown['.form-error-msg']).toBe('profile_save_failed');
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8338,8 +8338,13 @@ body.dashboard-mode .notifications-close-all {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
/* Shrinks and scrolls when the card is taller than the screen, so the
|
||||
header and footer stay put. */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 20px 18px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.dashboard-dialog-icon {
|
||||
@@ -8405,9 +8410,13 @@ body.dashboard-mode .notifications-close-all {
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-dialog-input {
|
||||
/* Scoped under the card so it outranks style.css's global
|
||||
`input[type=text]` padding and its 2px `:focus` border. */
|
||||
.dashboard-dialog .dashboard-dialog-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
@@ -8419,8 +8428,10 @@ body.dashboard-mode .notifications-close-all {
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-dialog-input:focus {
|
||||
.dashboard-dialog .dashboard-dialog-input:focus {
|
||||
padding: 0 12px;
|
||||
outline: none;
|
||||
border-width: 1px;
|
||||
border-color: var(--select-color);
|
||||
background: var(--dashboard-card-background);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
@@ -8493,6 +8504,37 @@ body.dashboard-mode .notifications-close-all {
|
||||
height: auto;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
/* Keeps its 32px look and hit-tests at 44x44. */
|
||||
.dashboard-dialog-close {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-dialog-close::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* 44px tall, and 16px text so iOS doesn't zoom in on focus. The
|
||||
.device-phone line outranks style.css's 17px phone inputs. */
|
||||
.dashboard-dialog .dashboard-dialog-input,
|
||||
.dashboard-dialog .dashboard-dialog-input:focus,
|
||||
.device-phone .dashboard-dialog .dashboard-dialog-input {
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Short screens (a phone on its side): give the card more of the height. */
|
||||
@media (max-height: 500px) {
|
||||
.dashboard-dialog-overlay {
|
||||
padding: 12px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: dock to the bottom as a slide-up sheet, like the other modals. */
|
||||
@@ -8505,7 +8547,9 @@ body.dashboard-mode .notifications-close-all {
|
||||
.dashboard-dialog {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 92vh;
|
||||
/* The overlay shrinks to what an on-screen keyboard leaves visible
|
||||
(see dashboardOverlay.js); the sheet shrinks with it. */
|
||||
max-height: min(92vh, calc(100% - 8px));
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-bottom: none;
|
||||
transform: translateY(24px);
|
||||
@@ -8520,9 +8564,31 @@ body.dashboard-mode .notifications-close-all {
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Above a keyboard the home indicator is out of the way. */
|
||||
.dashboard-dialog-overlay-keyboard .dashboard-dialog-footer {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* A form's buttons share one row, leaving the fields as much of the
|
||||
screen as a keyboard allows. */
|
||||
.dashboard-form-dialog .dashboard-dialog-footer {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.dashboard-form-dialog .dashboard-dialog-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The tone icon sits above the content so fields keep the full width. */
|
||||
.dashboard-form-dialog .dashboard-dialog-body {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -8531,3 +8597,271 @@ body.dashboard-mode .notifications-close-all {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ======================================
|
||||
Dashboard form dialog (account changes)
|
||||
====================================== */
|
||||
|
||||
.dashboard-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-form-field[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* margin and text-shadow undo style.css's global `label` rule; its white
|
||||
shadow ghosts the text on the dark theme. */
|
||||
.dashboard-form-label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-secondary);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.dashboard-form-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-dialog .dashboard-dialog-input[aria-invalid='true'] {
|
||||
border-color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-dialog .dashboard-dialog-input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dashboard-dialog textarea.dashboard-dialog-input,
|
||||
.dashboard-dialog textarea.dashboard-dialog-input:focus {
|
||||
height: auto;
|
||||
min-height: 96px;
|
||||
padding: 9px 12px;
|
||||
line-height: 1.45;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dashboard-dialog select.dashboard-dialog-input,
|
||||
.dashboard-dialog select.dashboard-dialog-input:focus {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
padding: 0 34px 0 12px;
|
||||
/* Chevron stroke: slate-500, as on the share dialog's selects. */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dashboard-dialog select.dashboard-dialog-input,
|
||||
.dashboard-dialog select.dashboard-dialog-input:focus {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
/* Password fields carry a show/hide toggle inside the box. */
|
||||
.dashboard-form-secret {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-dialog .dashboard-form-secret .dashboard-dialog-input,
|
||||
.dashboard-dialog .dashboard-form-secret .dashboard-dialog-input:focus {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.dashboard-form-reveal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 3px;
|
||||
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-muted);
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-form-reveal svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-form-reveal:hover {
|
||||
background: var(--dashboard-hover);
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-form-reveal:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dashboard-form-reveal {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status line (aria-live). Collapses when empty rather than going
|
||||
display:none so it stays in the a11y tree; the negative margin swallows
|
||||
the content column's gap it would otherwise still claim. */
|
||||
.dashboard-form-status {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-form-status:empty {
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
margin-top: -14px;
|
||||
}
|
||||
|
||||
.dashboard-form-status-error {
|
||||
border-color: var(--dashboard-danger-border);
|
||||
background: var(--dashboard-danger-background);
|
||||
color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-form-status-info {
|
||||
border-color: var(--dashboard-notice-border);
|
||||
background: var(--dashboard-notice-background);
|
||||
color: var(--dashboard-notice-text);
|
||||
}
|
||||
|
||||
/* Submit: a spinner while the change is in flight. */
|
||||
.dashboard-form-spinner {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 8px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
animation: dashboard-form-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.dashboard-form-busy .dashboard-form-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dashboard-form-submit:disabled {
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@keyframes dashboard-form-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Done state */
|
||||
.dashboard-form-done {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dashboard-form-done svg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--dashboard-success-text);
|
||||
}
|
||||
|
||||
.dashboard-form-done p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--dashboard-text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* API token, shown once */
|
||||
.dashboard-token-warning {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dashboard-warning-border);
|
||||
border-radius: 8px;
|
||||
background: var(--dashboard-warning-background);
|
||||
color: var(--dashboard-warning-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dashboard-token-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-token-copy {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-token-sessions {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dashboard-link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-token-sessions:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-token-sessions:focus-visible {
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dashboard-token-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-form-spinner {
|
||||
animation-duration: 1.6s;
|
||||
}
|
||||
}
|
||||
@@ -5257,37 +5257,6 @@ html.dark-mode .usage-table-show-less:hover {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.account-deletion-confirmation-prompt {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
padding: 20px;
|
||||
font-weight: 400;
|
||||
margin: -10px 10px 20px 10px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #5f626d;
|
||||
}
|
||||
|
||||
.account-deletion-confirmation-icon {
|
||||
width: 70px;
|
||||
margin: 20px auto 20px;
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.proceed-with-user-deletion {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.confirm-temporary-user-deletion {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.confirm-user-deletion-password {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Session manager — responsive modal (not a UIWindow)
|
||||
========================================================================== */
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 { openRevalidatePopup } from '../util/openid.js';
|
||||
import { fetchWithSessionCookieRetry, isSessionAuthError } from '../util/sessionAuth.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} UserProtectedResult
|
||||
* @property {boolean} ok The server accepted the change.
|
||||
* @property {Record<string, any>} data The response body (`{}` when it had none).
|
||||
* @property {string} [message] What to show when `ok` is false.
|
||||
* @property {boolean} [reauth] The session cookie could not be restored; the
|
||||
* sign-in flow has been started and the user has to try again after it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* POSTs an account change to `/user-protected/<route>`, the way every
|
||||
* account dialog has to. Those routes authenticate with the session cookie
|
||||
* only: the request carries no Authorization header, a lost cookie is minted
|
||||
* again from the GUI token once, and an account that signs in through a
|
||||
* linked provider proves itself in the provider's popup instead of with a
|
||||
* password — up front when the account has no password, or when the server
|
||||
* asks for it.
|
||||
*
|
||||
* @param {string} route The part after `/user-protected/`.
|
||||
* @param {Record<string, unknown>} body The change itself, sent on every attempt.
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.password] Sent on the first attempt only; signing in
|
||||
* with the provider stands in for it on a retry.
|
||||
* @param {() => void} [opts.onRevalidate] Runs just before the sign-in popup opens.
|
||||
* @returns {Promise<UserProtectedResult>}
|
||||
*/
|
||||
export default async function postUserProtected (route, body, { password, onRevalidate } = {}) {
|
||||
const origin = window.gui_origin || window.api_origin || '';
|
||||
const post = (payload) => fetchWithSessionCookieRetry(() => fetch(`${origin}/user-protected/${route}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}), { origin, authToken: window.auth_token });
|
||||
|
||||
const settle = async (res) => {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if ( isSessionAuthError(res, data) ) {
|
||||
window.handleReauthRequired({ reason: data.reason, auth_id: data.auth_id });
|
||||
return { ok: false, data, reauth: true, message: i18n('reauth_required_message', [], false) };
|
||||
}
|
||||
if ( res.ok ) return { ok: true, data };
|
||||
return { ok: false, data, message: data.message || res.statusText || i18n('error_unknown_cause', [], false) };
|
||||
};
|
||||
|
||||
const revalidate = async (url) => {
|
||||
onRevalidate?.();
|
||||
try {
|
||||
await openRevalidatePopup(url || window.user?.oidc_revalidate_url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const revalidateFailed = () => ({ ok: false, data: {}, message: i18n('revalidate_failed', [], false) });
|
||||
|
||||
// A temporary account has no password either, but no linked provider: the
|
||||
// routes that admit it (deleting it) need no proof of identity at all.
|
||||
if ( window.user?.oidc_only && ! window.user.is_temp && ! password ) {
|
||||
if ( ! await revalidate() ) return revalidateFailed();
|
||||
return settle(await post(body));
|
||||
}
|
||||
|
||||
const result = await settle(await post(password ? { ...body, password } : body));
|
||||
// `oidc_only` on the cached user can lag the account's real state, so
|
||||
// the server has the last word on whether a provider sign-in is needed.
|
||||
if ( result.data.code === 'oidc_revalidation_required' && result.data.revalidate_url ) {
|
||||
if ( ! await revalidate(result.data.revalidate_url) ) return revalidateFailed();
|
||||
return settle(await post(body));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../util/openid.js', () => ({
|
||||
openRevalidatePopup: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
|
||||
const { default: postUserProtected } = await import('./postUserProtected.js');
|
||||
const { openRevalidatePopup } = await import('../util/openid.js');
|
||||
|
||||
const reply = (status, data = {}) => new Response(JSON.stringify(data), { status });
|
||||
const urls = () => fetch.mock.calls.map(([url]) => String(url));
|
||||
const bodyOf = (index) => JSON.parse(fetch.mock.calls[index][1].body);
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn();
|
||||
globalThis.window = {
|
||||
gui_origin: 'https://puter.test',
|
||||
auth_token: 'tok-1',
|
||||
user: { oidc_only: false },
|
||||
handleReauthRequired: vi.fn(),
|
||||
};
|
||||
openRevalidatePopup.mockReset();
|
||||
openRevalidatePopup.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('postUserProtected', () => {
|
||||
it('posts the change with the password, by cookie and without an Authorization header', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200, { success: true }));
|
||||
|
||||
const result = await postUserProtected('change-username', { new_username: 'bob' }, { password: 'pw' });
|
||||
|
||||
expect(result).toEqual({ ok: true, data: { success: true } });
|
||||
expect(urls()).toEqual(['https://puter.test/user-protected/change-username']);
|
||||
expect(fetch.mock.calls[0][1].credentials).toBe('include');
|
||||
expect(fetch.mock.calls[0][1].headers).not.toHaveProperty('Authorization');
|
||||
expect(bodyOf(0)).toEqual({ new_username: 'bob', password: 'pw' });
|
||||
});
|
||||
|
||||
// The route is cookie-gated, so a session cookie that went missing
|
||||
// mid-session reads as a plain 401.
|
||||
it('mints the session cookie and retries when the submit 401s', async () => {
|
||||
fetch
|
||||
.mockResolvedValueOnce(reply(401, { code: 'token_missing' }))
|
||||
.mockResolvedValueOnce(reply(200))
|
||||
.mockResolvedValueOnce(reply(200, { success: true }));
|
||||
|
||||
const result = await postUserProtected('delete-own-user', {}, { password: 'pw' });
|
||||
|
||||
expect(urls()[1]).toContain('/session/sync-cookie');
|
||||
expect(urls()[2]).toContain('/user-protected/delete-own-user');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(window.handleReauthRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the sign-in flow when the cookie cannot be minted', async () => {
|
||||
fetch
|
||||
.mockResolvedValueOnce(reply(401, { code: 'token_missing' }))
|
||||
.mockResolvedValueOnce(reply(401));
|
||||
|
||||
const result = await postUserProtected('delete-own-user', {}, { password: 'pw' });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reauth: true, message: 'reauth_required_message' });
|
||||
expect(window.handleReauthRequired).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The server reports `oidc_only` for any passwordless account, temporary ones included.
|
||||
it('posts straight away for a temporary account, with no sign-in popup', async () => {
|
||||
window.user = { oidc_only: true, is_temp: true };
|
||||
fetch.mockResolvedValueOnce(reply(200, { success: true }));
|
||||
|
||||
const result = await postUserProtected('delete-own-user', {});
|
||||
|
||||
expect(openRevalidatePopup).not.toHaveBeenCalled();
|
||||
expect(bodyOf(0)).toEqual({});
|
||||
expect(result).toEqual({ ok: true, data: { success: true } });
|
||||
});
|
||||
|
||||
it('signs a provider-only account in first and sends no password', async () => {
|
||||
window.user = { oidc_only: true, oidc_revalidate_url: 'https://puter.test/revalidate' };
|
||||
const onRevalidate = vi.fn();
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
|
||||
await postUserProtected('change-password', { new_pass: 'New1!pass' }, { onRevalidate });
|
||||
|
||||
expect(onRevalidate).toHaveBeenCalledOnce();
|
||||
expect(openRevalidatePopup).toHaveBeenCalledWith('https://puter.test/revalidate');
|
||||
expect(bodyOf(0)).toEqual({ new_pass: 'New1!pass' });
|
||||
});
|
||||
|
||||
// `oidc_only` on the cached user can lag the account's real state, so
|
||||
// the server-driven retry has to carry the change itself too.
|
||||
it('retries with the change but not the password when the server asks for a provider sign-in', async () => {
|
||||
fetch
|
||||
.mockResolvedValueOnce(reply(403, { code: 'oidc_revalidation_required', revalidate_url: 'https://puter.test/r2' }))
|
||||
.mockResolvedValueOnce(reply(200));
|
||||
|
||||
const result = await postUserProtected('change-email', { new_email: '[email protected]', code: '000123' }, { password: 'pw' });
|
||||
|
||||
expect(openRevalidatePopup).toHaveBeenCalledWith('https://puter.test/r2');
|
||||
expect(bodyOf(1)).toEqual({ new_email: '[email protected]', code: '000123' });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('gives up without posting when the sign-in popup is closed', async () => {
|
||||
window.user = { oidc_only: true, oidc_revalidate_url: 'https://puter.test/revalidate' };
|
||||
openRevalidatePopup.mockRejectedValueOnce(new Error('Popup closed'));
|
||||
|
||||
const result = await postUserProtected('delete-own-user', {});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, message: 'revalidate_failed' });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the server message through on any other failure', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(400, { message: 'Username taken', code: 'bad_request' }));
|
||||
|
||||
const result = await postUserProtected('change-username', { new_username: 'x' }, { password: 'pw' });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, message: 'Username taken' });
|
||||
expect(window.handleReauthRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,7 @@ const en = {
|
||||
revalidated: 'Re-validated.',
|
||||
revalidate_sign_in_popup: 'Sign in with your linked account in the popup.',
|
||||
revalidate_flow_notice: 'You will be asked to sign in with your linked account when you continue.',
|
||||
revalidate_failed: 'Signing in with your linked account didn\u2019t finish. Please try again.',
|
||||
reauth_required_message: 'Your session was updated for security — please sign in again.',
|
||||
color_depth: 'Color Depth',
|
||||
close: 'Close',
|
||||
@@ -224,6 +225,7 @@ const en = {
|
||||
get_a_copy_of_on_puter: 'Get a copy of \'%%\' on Puter.com!',
|
||||
get_copy_link: 'Get Copy Link',
|
||||
hide_all_windows: 'Hide All Windows',
|
||||
hide_password: 'Hide password',
|
||||
home: 'Home',
|
||||
html_document: 'HTML document',
|
||||
hue: 'Hue',
|
||||
@@ -601,6 +603,7 @@ const en = {
|
||||
shortcut_to: 'Shortcut to',
|
||||
show_all_windows: 'Show All Windows',
|
||||
show_hidden: 'Show hidden',
|
||||
show_password: 'Show password',
|
||||
sign_in: 'Sign in',
|
||||
sign_in_with_puter: 'Sign in with Puter',
|
||||
sign_up: 'Sign Up',
|
||||
|
||||
@@ -53,6 +53,10 @@ export const openRevalidatePopup = async (revalidateUrl) => {
|
||||
}
|
||||
let doneCalled = false;
|
||||
const popup = window.open(url, 'puter-revalidate', POPUP_FEATURES);
|
||||
// A blocked popup never closes, so waiting for it would never end.
|
||||
if ( ! popup ) {
|
||||
throw new Error('Popup blocked');
|
||||
}
|
||||
const onMessage = ev => {
|
||||
if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return;
|
||||
if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
|
||||
const { openRevalidatePopup } = await import('./openid.js');
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('openRevalidatePopup', () => {
|
||||
it('rejects when the browser blocks the popup', async () => {
|
||||
vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
await expect(openRevalidatePopup('https://puter.test/revalidate')).rejects.toThrow('Popup blocked');
|
||||
});
|
||||
|
||||
it('rejects once the popup is closed without finishing', async () => {
|
||||
vi.useFakeTimers();
|
||||
const popup = { closed: false };
|
||||
vi.spyOn(window, 'open').mockReturnValue(popup);
|
||||
const result = openRevalidatePopup('https://puter.test/revalidate');
|
||||
popup.closed = true;
|
||||
vi.advanceTimersByTime(300);
|
||||
await expect(result).rejects.toThrow('Popup closed');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user