From a983969f32a887be4ba67e7ecee56495ffbc2194 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 10 Sep 2026 16:33:30 -0400 Subject: [PATCH] feat: prompt a team seat to choose its own password on first sign-in The backend already refused every route with `password_change_required` until a provisioned account replaced the password its administrator chose, and `/user-protected/change-password` was already exempt so the account could act. The client half was missing entirely: nothing in the GUI referenced that code, so a seat signed in and then failed at everything with no prompt and no way out. Two gaps, both closed here. The flag never reached the client. Neither the login response nor `/whoami` carried `requires_password_change`, so the GUI could not have known even if it wanted to. It ships from both now, alongside the other three verification flags the whoami extension already describes as "the flags the GUI acts on". There was no window to show. `UIWindowChangePassword` is a settings dialog -- closable, and it never resolves on success -- so it cannot act as a gate. `UIWindowPasswordChangeRequired` mirrors the existing `*Required` windows: it resolves true only once the change lands, and `initgui` loops on it. It runs last in the boot chain, matching the server order in assertVerifiedAccount. It refuses a new password equal to the current one. Without that the account stays on the credential its administrator still holds, which is the entire thing the gate exists to end. Falsified twice: dropping the same-password guard fails "refuses to reuse the password the admin handed over", and resolving on a rejected response fails "stays open on a rejected change, so the gate cannot be escaped" -- each breaking only its own test. 175 backend tests, 326 GUI/SDK tests, typecheck clean. --- extensions/whoami.ts | 5 +- .../controllers/auth/AuthController.ts | 2 + .../src/UI/UIWindowPasswordChangeRequired.js | 142 ++++++++++++++++++ .../UI/UIWindowPasswordChangeRequired.test.js | 138 +++++++++++++++++ src/gui/src/i18n/translations/en.js | 3 + src/gui/src/initgui.js | 15 ++ 6 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 src/gui/src/UI/UIWindowPasswordChangeRequired.js create mode 100644 src/gui/src/UI/UIWindowPasswordChangeRequired.test.js 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;