Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 8bcfd0c620 fix(dashboard): temporary accounts can delete themselves again
The server reports oidc_only for every passwordless account, temporary
ones included, so the shared user-protected helper sent a temporary
account into the linked-provider sign-in, which has no URL for it, and the
deletion never went out. Temporary accounts post directly, as the route
admits them without credentials.
2026-10-22 16:59:08 -07:00
Nariman Jelveh df2c5978b1 fix(dashboard): phone dialog inputs stay 16px instead of jumping on focus
On phones style.css's .device-phone input rule outranked the dialog's
16px size at rest, so fields drew at 17px and shrank to 16px the moment
they took focus.
2026-10-22 16:56:57 -07:00
Nariman Jelveh 831fd421a0 fix(dashboard): dialog inputs keep a 1px border when focused
style.css's global input :focus rule still set a 2px border width, so a
dialog field's text shifted by a pixel each time it took focus. The focus
ring is the box-shadow; the border stays 1px.
2026-10-22 16:56:26 -07:00
Nariman Jelveh b33286b106 fix(dashboard): form dialog labels drop the global label shadow
style.css's bare `label` rule gave the field labels a white text-shadow,
which ghosted them on the dark theme, and an extra 3px under each label.
2026-10-22 16:55:01 -07:00
Nariman Jelveh 8e7c64ad55 fix(dashboard): requesting an email change no longer sets the cached email
The server only stages the new address until the emailed link is
followed, but the dialog wrote it into window.user.email straight away.
That cached a wrong address for the session and kept refresh_user_data from
updating the Account tab once the change was confirmed, since the two
already matched.
2026-10-22 16:53:35 -07:00
Nariman Jelveh bc59376884 fix(dashboard): screen readers hear a form dialog's success message
The done state arrives as a new status region that already holds its text,
which screen readers don't announce, so after a change succeeded they only
heard "Done, button". The Done button that takes focus is now described by
the message.
2026-10-22 16:52:16 -07:00
Nariman Jelveh c327ff6d69 fix(dashboard): password show/hide reads as one action, not a pressed toggle
The button flipped its label between Show and Hide password and also set
aria-pressed, so assistive tech announced "Hide password, pressed" while
the password was showing. It keeps the action label and drops aria-pressed.
2026-10-22 16:51:17 -07:00
Nariman Jelveh 992e24b943 fix(gui): a blocked re-sign-in popup fails instead of waiting forever
openRevalidatePopup polled a popup that was never opened, so when the
browser blocked it the account dialog stayed busy indefinitely. It now
rejects straight away and the dialog says the sign-in didn't finish.
2026-10-22 16:50:38 -07:00
Nariman Jelveh 987521ffd4 fix(dashboard): a form error keeps the field it names in view
The status line scrolled the dialog body to the bottom, which on a short
screen (a phone with its keyboard up) left the field being focused and
marked invalid scrolled off the top. The body now scrolls back up just
far enough to show that field.
2026-10-22 16:49:35 -07:00
Nariman Jelveh 06401516bf fix(dashboard): a failed form submit keeps focus in the dialog
Locking the fields disabled the focused control, which drops focus to the
page, so after an error Enter no longer retried and screen readers lost
their place. The control that had focus gets it back once the fields
unlock, unless the handler moved focus elsewhere in the dialog.
2026-10-22 16:48:51 -07:00
Nariman Jelveh 15cbb041dc fix(dashboard): a double-click that opens a dialog no longer dismisses it
The second press of the double-click lands on the freshly mounted backdrop
and closed the dialog straight away. Backdrop presses with a click count
above one no longer count toward dismissal.
2026-10-22 16:46:55 -07:00
Nariman Jelveh 337006d99c feat(dashboard): account dialogs as dashboard modals instead of windows
Edit profile, change username, change password, change email, create API
token, and delete account in the Account tab (and change password in the
Security tab) now open as dashboard overlay cards, like the share,
properties, and confirm dialogs, instead of desktop UIWindows. They are
centered cards on desktop and tablet and bottom sheets on phones.

- dashboardOverlay.js: the overlay shell UIDashboardDialog already had
  (reveal, Esc/backdrop/close dismissal, focus trap, focus return), pulled
  out so the new form dialog shares it. It also pins the overlay to the
  visual viewport so iOS's keyboard can't cover a sheet's fields, and
  leaves Esc to a modal opened on top of it.
- UIDashboardFormDialog.js: labelled fields, password show/hide, an
  aria-live status line that marks and focuses the offending field, a busy
  submit button, and a done state.
- postUserProtected.js: the cookie-retry / provider re-sign-in / reauth
  flow the four account windows each carried, in one place.
- Deleting an account is one dialog now (warning plus confirmation) rather
  than two stacked windows.
- Touch: 44px controls, 16px inputs (no iOS focus zoom), no autofocus
  that pops the keyboard mid-animation, form buttons side by side on
  phones, safe-area padding dropped while the keyboard is up.
- The replaced windows had no other callers and are removed with their
  tests, which are ported to the new modules.
2026-10-22 16:32:32 -07:00
25 changed files with 2014 additions and 1525 deletions

No files matched your search

+23 -75
View File
@@ -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;
+3 -12
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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');
});
});
+22 -67
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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"> &times; </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"> &times; </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"> &times; </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"> &times; </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();
});
});
-237
View File
@@ -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!' });
});
});
-116
View File
@@ -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();
});
});
+337 -3
View File
@@ -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;
}
}
-31
View File
@@ -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)
========================================================================== */
+94
View File
@@ -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();
});
});
+3
View File
@@ -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',
+4
View File
@@ -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;
+28
View File
@@ -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');
});
});