diff --git a/extensions/whoami.ts b/extensions/whoami.ts index a2e10dcc4..049788b7d 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -156,6 +156,8 @@ export const handleWhoami = async ( // every app actor. Only the verification flag ships. requires_phone_verification: user.requires_phone_verification, requires_card_verification: user.requires_card_verification, + // A seat reaches nothing until it replaces its admin's password. + requires_password_change: user.requires_password_change, // The SMS-to-card escape hatch: true once this user is out of SMS send // attempts and may verify a card instead. It has to ship from here // because /send-confirm-phone can no longer say so — by the time the @@ -292,8 +294,7 @@ export const handleWhoami = async ( } const subscription = details.subscription as - | { offering?: Record } - | undefined; + { offering?: Record } | undefined; if (subscription?.offering) { delete subscription.offering.group; delete subscription.offering.benefits; diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 25eda395c..78a362686 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -4816,6 +4816,7 @@ export class AuthController extends PuterController { phone?: string | null; requires_phone_verification?: number | boolean; requires_card_verification?: number | boolean; + requires_password_change?: number | boolean; }, ): Promise { const meta = { @@ -4868,6 +4869,7 @@ export class AuthController extends PuterController { phone: user.phone, requires_phone_verification: user.requires_phone_verification, requires_card_verification: user.requires_card_verification, + requires_password_change: user.requires_password_change, is_temp: user.password === null && user.email === null, taskbar_items, }, diff --git a/src/gui/src/UI/UIWindowPasswordChangeRequired.js b/src/gui/src/UI/UIWindowPasswordChangeRequired.js new file mode 100644 index 000000000..90839904a --- /dev/null +++ b/src/gui/src/UI/UIWindowPasswordChangeRequired.js @@ -0,0 +1,142 @@ +/* + * 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 . + */ + +import check_password_strength from '../helpers/checkPasswordStrength.js'; +import UIWindow from './UIWindow.js'; + +/** Resolves true once a seat replaces the password its administrator chose. */ +function UIWindowPasswordChangeRequired (options) { + return new Promise(async (resolve) => { + options = options ?? {}; + options.window_options = options.window_options ?? {}; + + let h = ''; + if ( options.show_close_button !== false ) { + h += '
×
'; + } + h += '
'; + h += `

${i18n('password_change_required_title')}

`; + h += `

${i18n('password_change_required_hint')}

`; + h += '
'; + h += '
'; + h += ``; + h += ''; + h += ``; + h += ''; + h += ``; + h += ''; + h += ``; + h += '
'; + h += '
'; + + const el_window = await UIWindow({ + title: null, + icon: null, + uid: null, + is_dir: false, + body_content: h, + has_head: false, + selectable_body: false, + draggable_body: true, + allow_context_menu: false, + is_resizable: false, + is_droppable: false, + init_center: true, + allow_native_ctxmenu: false, + allow_user_select: false, + backdrop: true, + width: 390, + height: 'auto', + dominant: true, + show_in_taskbar: false, + onAppend: function (this_window) { + $(this_window).find('.pcr-current').get(0)?.focus({ preventScroll: true }); + }, + window_class: 'window-login', + 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 $err = $(el_window).find('.form-error-msg'); + + const fail = (message) => { + $err.html(html_encode(message)).fadeIn(); + $(el_window).find('.pcr-btn').removeClass('disabled'); + $(el_window).find('.pcr-current, .pcr-new, .pcr-confirm').attr('disabled', false); + }; + + $(el_window).find('form').on('submit', async function (e) { + e.preventDefault(); + const current_password = $(el_window).find('.pcr-current').val(); + const new_password = $(el_window).find('.pcr-new').val(); + const confirm_new_password = $(el_window).find('.pcr-confirm').val(); + + $err.hide(); + if ( !current_password || !new_password || !confirm_new_password ) { + return fail(i18n('all_fields_required')); + } + if ( new_password !== confirm_new_password ) { + return fail(i18n('passwords_do_not_match')); + } + // Otherwise the account is still on the credential its admin holds. + if ( new_password === current_password ) { + return fail(i18n('password_change_required_same')); + } + const strength = check_password_strength(new_password); + if ( !strength.overallPass ) { + return fail(i18n('password_strength_error')); + } + + $(el_window).find('.pcr-btn').addClass('disabled'); + $(el_window).find('.pcr-current, .pcr-new, .pcr-confirm').attr('disabled', true); + + let res; + try { + res = await fetch(`${origin}/user-protected/change-password`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + password: current_password, + new_pass: new_password, + }), + }); + } catch (err) { + return fail(err?.message || 'Request failed'); + } + + if ( res.ok ) { + if ( window.user ) window.user.requires_password_change = false; + $(el_window).close(); + resolve(true); + return; + } + const data = await res.json().catch(() => ({})); + fail(data.message || res.statusText || 'Request failed'); + }); + }); +} + +export default UIWindowPasswordChangeRequired; diff --git a/src/gui/src/UI/UIWindowPasswordChangeRequired.test.js b/src/gui/src/UI/UIWindowPasswordChangeRequired.test.js new file mode 100644 index 000000000..02e20591b --- /dev/null +++ b/src/gui/src/UI/UIWindowPasswordChangeRequired.test.js @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Stubbed down to the element the submit handler binds to. +const state = vi.hoisted(() => ({ el: null, closed: 0 })); + +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' })), +})); + +globalThis.i18n = (key) => key; +globalThis.html_encode = (value) => String(value); + +/** Minimal jQuery stand-in: a selector -> value map drives the handler. */ +const fields = {}; +let submitHandler = null; +globalThis.$ = () => ({ + find: (sel) => ({ + val: () => fields[sel], + on: (evt, fn) => { + if (evt === 'submit') submitHandler = fn; + }, + html: () => ({ fadeIn: () => {} }), + hide: () => {}, + fadeIn: () => {}, + addClass: () => {}, + removeClass: () => {}, + attr: () => {}, + get: () => [undefined], + }), + close: () => { + state.closed++; + }, +}); + +const { default: UIWindowPasswordChangeRequired } = await import( + './UIWindowPasswordChangeRequired.js' +); + +const submit = async () => { + await submitHandler({ preventDefault: () => {} }); +}; + +describe('the forced password-change gate', () => { + beforeEach(() => { + globalThis.fetch = vi.fn(); + globalThis.window = { user: { requires_password_change: true } }; + state.closed = 0; + submitHandler = null; + fields['.pcr-current'] = 'TempPass1!'; + fields['.pcr-new'] = 'ChosenPass1!'; + fields['.pcr-confirm'] = 'ChosenPass1!'; + }); + + it('posts to the one route the gate lets through, with credentials', async () => { + globalThis.fetch.mockResolvedValue({ ok: true }); + const gate = UIWindowPasswordChangeRequired({ show_close_button: false }); + await Promise.resolve(); + await submit(); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + const [url, init] = globalThis.fetch.mock.calls[0]; + expect(url).toContain('/user-protected/change-password'); + // The user-protected gate is cookie-only; a bearer token is refused. + expect(init.credentials).toBe('include'); + expect(JSON.parse(init.body)).toEqual({ + password: 'TempPass1!', + new_pass: 'ChosenPass1!', + }); + await expect(gate).resolves.toBe(true); + }); + + it('clears the local flag and closes once the change lands', async () => { + globalThis.fetch.mockResolvedValue({ ok: true }); + UIWindowPasswordChangeRequired({ show_close_button: false }); + await Promise.resolve(); + await submit(); + + expect(globalThis.window.user.requires_password_change).toBe(false); + expect(state.closed).toBe(1); + }); + + it('refuses to reuse the password the admin handed over', async () => { + // The whole point of the gate: the admin still knows this one. + fields['.pcr-new'] = 'TempPass1!'; + fields['.pcr-confirm'] = 'TempPass1!'; + UIWindowPasswordChangeRequired({ show_close_button: false }); + await Promise.resolve(); + await submit(); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('does not submit a mismatch, a blank, or a weak password', async () => { + const cases = [ + { '.pcr-confirm': 'Different1!' }, + { '.pcr-new': '' }, + { '.pcr-new': 'weak', '.pcr-confirm': 'weak' }, + ]; + for (const over of cases) { + fields['.pcr-current'] = 'TempPass1!'; + fields['.pcr-new'] = 'ChosenPass1!'; + fields['.pcr-confirm'] = 'ChosenPass1!'; + Object.assign(fields, over); + submitHandler = null; + UIWindowPasswordChangeRequired({ show_close_button: false }); + await Promise.resolve(); + await submit(); + } + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('stays open on a rejected change, so the gate cannot be escaped', async () => { + globalThis.fetch.mockResolvedValue({ + ok: false, + statusText: 'Forbidden', + json: async () => ({ message: 'Wrong password' }), + }); + UIWindowPasswordChangeRequired({ show_close_button: false }); + await Promise.resolve(); + await submit(); + + expect(state.closed).toBe(0); + expect(globalThis.window.user.requires_password_change).toBe(true); + }); + + it('omits the close button when it is a gate', async () => { + UIWindowPasswordChangeRequired({ show_close_button: false }); + await Promise.resolve(); + expect(state.el.body).not.toContain('generic-close-window-button'); + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 221e7357a..2d3b852fb 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -281,6 +281,9 @@ const en = { password_recovery_token_invalid: 'This password recovery token is no longer valid.', password_recovery_unknown_error: 'An unknown error occurred. Please try again later.', password_required: 'Password is required.', + password_change_required_title: 'Choose your own password', + password_change_required_hint: 'This account was created for you, and its password is still the one you were given. Pick your own to continue.', + password_change_required_same: 'Your new password must be different from the one you were given.', password_strength_error: 'Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.', passwords_do_not_match: '`New Password` and `Confirm New Password` do not match.', paste: 'Paste', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 2a9723a72..ce3e4bea4 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -28,6 +28,7 @@ import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js'; import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js'; +import UIWindowPasswordChangeRequired from './UI/UIWindowPasswordChangeRequired.js'; import UIWindowPhoneVerificationRequired from './UI/UIWindowPhoneVerificationRequired.js'; import UIWindowCardVerificationRequired from './UI/UIWindowCardVerificationRequired.js'; import { openVerificationGateWindow } from './helpers/verification_gates.js'; @@ -1797,6 +1798,20 @@ window.initgui = async function (options) { }); } while (!is_verified); } + // Last, matching assertVerifiedAccount's order. + if (whoami.requires_password_change) { + let changed; + do { + changed = await UIWindowPasswordChangeRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + window_options: { + is_draggable: false, + }, + }); + } while (!changed); + } // if user is logging in using an auth token that means it's not their first ever visit to Puter.com // it might be their first visit to Puter on this specific device but it's not their first time ever visiting Puter. window.first_visit_ever = false;