From b19b312e30cbfe7db0befaa2f67127a65d29d89a Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 10 Sep 2026 15:55:50 -0400 Subject: [PATCH 01/27] feat: a team seat needs no email address, and is never asked to confirm one PUT-1792. Two separate problems, both from treating a provisioned account like a self-registered one. `email` was required, so an admin creating ten seats had to invent ten addresses and then keep track of ten uniqueness constraints -- for accounts that sign in by username and never use the address. It is now optional at every layer, and the add-account form does not ask for it at all: username is the only thing a seat needs. `requires_email_confirmation` was set to true, with the reasoning that an admin-supplied address is unverified. True, but `requireVerifiedAccount` turns away on exactly `requires_email_confirmation && !email_confirmed`, so a freshly created seat was asked to confirm an address it may not hold and could not use the product until it did. The team creating the account is the trust anchor, not the mailbox, so this is now false either way. An address is still accepted and still stored when given, because the notices are worth delivering. `#notifyUser` already returned early on a missing address, so `team_account_created`, `team_account_disabled`, `team_password_reset` and `team_closed` degrade quietly with no new branching -- the temporary password is in the API response, which is the documented delivery. `idx_user_owned_email` is partial and skips password-null rows, so omitting the address sidesteps it rather than creating a collision surface. Two seats with no address do not conflict, and there is a test for it. Docs now say an emailless seat is recoverable only through its team's owner. That falls out of the design rather than being a limitation of this change, but it should be written down rather than discovered. Falsified: putting `requires_email_confirmation: true` back fails "never demands confirmation, with or without an address" with `expected true to be false`, and nothing else. 164 team tests, 40 SDK tests, typecheck clean. --- .../controllers/team/TeamController.ts | 13 +++- src/backend/services/team/TeamService.test.ts | 67 +++++++++++++++++++ src/backend/services/team/TeamService.ts | 17 ++--- src/docs/src/Teams/createMember.md | 9 ++- src/gui/src/UI/Dashboard/TabTeams.js | 6 +- .../src/modules/teams/createMember.js | 11 ++- src/puter-js/src/modules/teams/teams.test.js | 21 +++++- src/puter-js/src/modules/teams/types.js | 4 +- 8 files changed, 127 insertions(+), 21 deletions(-) diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts index 07cefecb58..59d5476532 100644 --- a/src/backend/controllers/team/TeamController.ts +++ b/src/backend/controllers/team/TeamController.ts @@ -249,7 +249,7 @@ export class TeamController extends PuterController { const body = this.#body(req); const result = await this.services.team.provisionAccount(uid, userId, { username: this.#requireString(body.username, 'username'), - email: this.#requireString(body.email, 'email'), + email: this.#optionalString(body.email, 'email'), }); // Shown once; the admin delivers it out of band. res.json({ @@ -426,6 +426,17 @@ export class TeamController extends PuterController { return (req.body ?? {}) as Record; } + /** Absent or empty means "not given"; a wrong type is still a 400. */ + #optionalString(value: unknown, field: string): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== 'string') { + throw new HttpError(400, `${field} must be a string`, { + legacyCode: 'bad_request', + }); + } + return value.trim() === '' ? null : value; + } + #requireString(value: unknown, field: string): string { if (typeof value !== 'string' || value.trim() === '') { throw new HttpError(400, `${field} is required`, { diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index 8a2e5cb725..bb676bb550 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -577,6 +577,73 @@ describe('TeamService', () => { } }); + // PUT-1792: seats sign in by username, so an address is optional. + it('provisions with no email at all', async () => { + const { team } = await makeTeam(); + const username = `noem_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + }); + + const row = await server.stores.user.getByProperty( + 'id', + created.userId, + { force: true }, + ); + expect(row?.email ?? null).toBeNull(); + expect(created.temporaryPassword).toEqual(expect.any(String)); + }); + + it('never demands confirmation, with or without an address', async () => { + // The gate is `requires_email_confirmation && !email_confirmed`. + const { team } = await makeTeam(); + const bare = `bare_${Math.random().toString(36).slice(2, 9)}`; + const withEmail = `wem_${Math.random().toString(36).slice(2, 9)}`; + + const a = await service.provisionAccount(team.uid, owner.id, { + username: bare, + }); + const b = await service.provisionAccount(team.uid, owner.id, { + username: withEmail, + email: `${withEmail}@test.local`, + }); + + for (const id of [a.userId, b.userId]) { + const row = await server.stores.user.getByProperty('id', id, { + force: true, + }); + expect(Boolean(row?.requires_email_confirmation)).toBe(false); + } + }); + + it('keeps an address when one is given', async () => { + const { team } = await makeTeam(); + const username = `kept_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + + const row = await server.stores.user.getByProperty( + 'id', + created.userId, + { force: true }, + ); + expect(row?.email).toBe(`${username}@test.local`); + }); + + it('does not collide two seats that both have no address', async () => { + // The uniqueness index is on the address; absent is not a value. + const { team } = await makeTeam(); + for (const n of [1, 2]) { + await expect( + service.provisionAccount(team.uid, owner.id, { + username: `dup${n}_${Math.random().toString(36).slice(2, 9)}`, + }), + ).resolves.toMatchObject({ username: expect.any(String) }); + } + }); + it('refuses an invalid email', async () => { const { team } = await makeTeam(); await expect( diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 7ab11c0bae..bd391f56e0 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -700,7 +700,7 @@ export class TeamService extends PuterService { async provisionAccount( teamUid: string, actorUserId: number, - input: { username: string; email: string }, + input: { username: string; email?: string | null }, ): Promise<{ userId: number; username: string; @@ -714,7 +714,7 @@ export class TeamService extends PuterService { async #provisionAccountLocked( teamUid: string, actorUserId: number, - input: { username: string; email: string }, + input: { username: string; email?: string | null }, ): Promise<{ userId: number; username: string; @@ -732,7 +732,8 @@ export class TeamService extends PuterService { } this.#assertUsableUsername(input.username); - if (!validator.isEmail(input.email)) { + const email = typeof input.email === 'string' ? input.email.trim() : ''; + if (email && !validator.isEmail(email)) { throw new HttpError(400, 'Invalid email', { legacyCode: 'bad_request', }); @@ -749,7 +750,7 @@ export class TeamService extends PuterService { } // `idx_user_owned_email` is partial and skips password-null rows. - if (await this.stores.user.findEmailOwner(input.email)) { + if (email && (await this.stores.user.findEmailOwner(email))) { throw new HttpError(409, 'That email is already in use', { legacyCode: 'email_already_in_use', }); @@ -759,10 +760,10 @@ export class TeamService extends PuterService { username: input.username, uuid: uuidv4(), password: null, - email: input.email, - clean_email: cleanEmail(input.email), - // The address came from the administrator, not its holder. - requires_email_confirmation: true, + email: email || null, + clean_email: email ? cleanEmail(email) : null, + // Never demanded: the team creating the account is the trust anchor. + requires_email_confirmation: false, }); await generateDefaultFsentries(this.clients.db, this.stores.user, user); diff --git a/src/docs/src/Teams/createMember.md b/src/docs/src/Teams/createMember.md index 0cd559b230..691a0b8f93 100644 --- a/src/docs/src/Teams/createMember.md +++ b/src/docs/src/Teams/createMember.md @@ -28,9 +28,13 @@ The team's identifier. The username for the new account. Usernames come from the same pool as ordinary sign-ups, so it must be free across the whole of Puter. -#### `options.email` (String) (required) +#### `options.email` (String) (optional) -The address the member is reachable at. It must not already own an account. The address came from the administrator rather than its holder, so the account is created needing email confirmation. +Where the team's notices about this account are delivered. These accounts sign in by **username**, so an address is not needed and the form does not ask for one. + +Supply it only if you want `team_account_created`, `team_account_disabled` and `team_password_reset` to reach the member; if you leave it out, those notices are simply not sent and the temporary password in the return value is the only delivery. If given, it must not already own an account. + +The account is never asked to confirm the address — the team creating it is the trust anchor — so it can be used immediately either way. An account with no address is recoverable only through its team's owner, via `resetPassword`. ## Return value @@ -53,7 +57,6 @@ Rejects with `username_already_in_use` — with free alternatives in `fields.sug const name = 'member' + Math.random().toString(36).slice(2, 8); const account = await puter.teams.createMember(team.uid, { username: name, - email: `${name}@example.com`, }); // Shown once; hand it over out of band. puter.print(`${account.username}: ${account.temporaryPassword}`); diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 577f4da302..2aa74977b4 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -124,7 +124,6 @@ const renderAddAccount = () => { h += `

${i18n('teams_add_account_hint')}

`; h += '
'; h += ``; - h += ``; h += ``; h += '
'; h += ''; @@ -310,13 +309,12 @@ const showCredential = ($el_window, username, temporaryPassword) => { const addAccount = async ($el_window) => { const username = $el_window.find(`${SECTION} .teams-new-username`).val().trim(); - const email = $el_window.find(`${SECTION} .teams-new-email`).val().trim(); - if ( ! username || ! email ) return; + if ( ! username ) return; const $button = $el_window.find(`${SECTION} .teams-add-btn`); $button.prop('disabled', true); try { - const created = await puter.teams.createMember(state.selected.uid, { username, email }); + const created = await puter.teams.createMember(state.selected.uid, { username }); await refresh($el_window); showCredential($el_window, created.username, created.temporaryPassword); } catch (e) { diff --git a/src/puter-js/src/modules/teams/createMember.js b/src/puter-js/src/modules/teams/createMember.js index 4fcbd24598..25a1c89b5b 100644 --- a/src/puter-js/src/modules/teams/createMember.js +++ b/src/puter-js/src/modules/teams/createMember.js @@ -10,6 +10,9 @@ import { req, requireSegment } from './lib/req.js'; * The returned password is shown once and is not retrievable afterwards — * deliver it out of band. The member must change it at first sign-in. * + * `email` is optional; these accounts sign in by username. Without one the + * account is only recoverable through its team's admin. + * * @this {import('./index.js').TeamsModule} * @param {string} uid * @param {import('./types.js').CreateMemberOptions} options @@ -20,12 +23,14 @@ export async function createMember (uid, options) { if ( typeof options?.username !== 'string' || options.username.trim() === '' ) { throw new PuterJSError('`username` is required', 'invalid_request'); } - if ( typeof options?.email !== 'string' || options.email.trim() === '' ) { - throw new PuterJSError('`email` is required', 'invalid_request'); + if ( options?.email !== undefined && options.email !== null + && typeof options.email !== 'string' ) { + throw new PuterJSError('`email` must be a string', 'invalid_request'); } + const email = typeof options?.email === 'string' ? options.email.trim() : ''; const result = /** @type {Record} */ (await req(this.puter, 'POST', `/teams/${segment}/members`, { - body: { username: options.username, email: options.email }, + body: { username: options.username, ...(email ? { email } : {}) }, operation: 'createMember', })); return { diff --git a/src/puter-js/src/modules/teams/teams.test.js b/src/puter-js/src/modules/teams/teams.test.js index 5a6ec1f183..d2f28bbc8f 100644 --- a/src/puter-js/src/modules/teams/teams.test.js +++ b/src/puter-js/src/modules/teams/teams.test.js @@ -193,8 +193,27 @@ describe('members', () => { expect(call().body).toEqual({ username: 'bob', email: 'bob@example.com' }); }); - it('refuses a member without an email without making a request', async () => { + it('provisions with no email, omitting the key rather than sending empty', async () => { + routes({ 'POST /teams/t-1/members': { username: 'bob', temporary_password: 'hunter2' } }); await expect(teams.createMember('t-1', { username: 'bob' })) + .resolves.toEqual({ username: 'bob', temporaryPassword: 'hunter2' }); + expect(call().body).toEqual({ username: 'bob' }); + }); + + it('treats a blank email as absent', async () => { + routes({ 'POST /teams/t-1/members': { username: 'bob', temporary_password: 'hunter2' } }); + await teams.createMember('t-1', { username: 'bob', email: ' ' }); + expect(call().body).toEqual({ username: 'bob' }); + }); + + it('refuses a non-string email without making a request', async () => { + await expect(teams.createMember('t-1', { username: 'bob', email: 42 })) + .rejects.toMatchObject({ code: 'invalid_request' }); + expect(mockReq).not.toHaveBeenCalled(); + }); + + it('still refuses a member without a username', async () => { + await expect(teams.createMember('t-1', {})) .rejects.toMatchObject({ code: 'invalid_request' }); expect(mockReq).not.toHaveBeenCalled(); }); diff --git a/src/puter-js/src/modules/teams/types.js b/src/puter-js/src/modules/teams/types.js index 6677e750c3..bbacb5cc69 100644 --- a/src/puter-js/src/modules/teams/types.js +++ b/src/puter-js/src/modules/teams/types.js @@ -55,7 +55,9 @@ * * @typedef {Object} CreateMemberOptions * @property {string} username The username for the new account. Must be free across all of Puter. - * @property {string} email The address the member is reachable at. It must not already own an account. + * @property {string} [email] Optional. These accounts sign in by username, so an + * address is not needed; supply one only to have the team's notices delivered. + * If given it must not already own an account. */ /** From a983969f32a887be4ba67e7ecee56495ffbc2194 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 10 Sep 2026 16:33:30 -0400 Subject: [PATCH 02/27] 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 a2e10dcc42..049788b7d6 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 25eda395c3..78a3626867 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 0000000000..90839904a8 --- /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 0000000000..02e20591be --- /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 221e7357a2..2d3b852fbb 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 2a9723a720..ce3e4bea46 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; From fb74789de19543df16b7b601430dcb76351e97b2 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 10 Sep 2026 16:41:03 -0400 Subject: [PATCH 03/27] feat: email a seat its username and temporary password, when an address is given Follows the previous commit. Dropping the email field entirely went one step too far: without an address the temporary password shown once in the panel is the only copy, and an admin who closes that panel has to issue a new one. The field is back, marked optional, and now it buys something concrete. `team_account_created` carried no credential -- it said the team "will send you a temporary password separately". It now carries the username and the temporary password, so an admin who supplies an address hands nothing over by side channel. `#notifyUser` takes extra template variables, and both credential-issuing paths pass the one they just minted: provisioning and re-issue. Re-issue passing the fresh credential rather than the stale one is the case worth checking, and there is a test that asserts the old password is absent from that mail. With no address nothing is sent, which was already true -- `#notifyUser` returns early without one -- and is now covered. The docs said the notice carries no credential in two places. Both corrected. Falsified: dropping the credential from the re-issue call fails "emails the fresh credential on re-issue, not the old one" and nothing else. 178 backend tests, 326 GUI/SDK tests, typecheck clean. --- src/backend/clients/email/templates.ts | 8 ++- src/backend/services/team/TeamService.test.ts | 69 ++++++++++++++++++- src/backend/services/team/TeamService.ts | 17 ++--- src/docs/src/Teams/createMember.md | 4 +- src/docs/src/Teams/resendActivation.md | 2 +- src/gui/src/UI/Dashboard/TabTeams.js | 9 ++- src/gui/src/i18n/translations/en.js | 2 + 7 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index 0eaf367305..8fed1986c8 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -351,9 +351,11 @@ support@puter.com immediately. subject: 'Your {{team_name}} account on Puter', html: `

Hi there,

-

{{team_name}} has created a Puter account for you: {{username}}. -They will send you a temporary password separately; you will be asked to -choose your own the first time you sign in.

+

{{team_name}} has created a Puter account for you.

+

Username: {{username}}
+Temporary password: {{temporary_password}}

+

You will be asked to choose your own password the first time you sign in. +This temporary one stops working then, and it expires on its own if unused.

What this means:

  • This account belongs to {{team_name}}. They pay for it and can close it.
  • diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index bb676bb550..3f8b7b3bef 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -938,21 +938,86 @@ describe('TeamService', () => { /** Captures what would go out, without standing up a transport. */ const captureMail = () => { - const sent: { to: string; subject: string }[] = []; + const sent: { to: string; subject: string; html: string }[] = []; const client = server.clients.email as unknown as { - sendRaw: (o: { to?: string; subject?: string }) => Promise; + sendRaw: (o: { + to?: string; + subject?: string; + html?: string; + }) => Promise; }; const original = client.sendRaw.bind(client); client.sendRaw = async (options) => { sent.push({ to: String(options.to ?? ''), subject: String(options.subject ?? ''), + html: String(options.html ?? ''), }); return null; }; return { sent, restore: () => (client.sendRaw = original) }; }; + it('emails the credential when an address is given', async () => { + const { team } = await makeTeam(); + const username = `mail_${Math.random().toString(36).slice(2, 9)}`; + + const mail = captureMail(); + let created; + try { + created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + } finally { + mail.restore(); + } + + expect(mail.sent).toHaveLength(1); + expect(mail.sent[0].to).toBe(`${username}@test.local`); + expect(mail.sent[0].html).toContain(username); + // The point of the address: without it this is the only copy. + expect(mail.sent[0].html).toContain(created.temporaryPassword); + }); + + it('sends nothing when no address is given', async () => { + const { team } = await makeTeam(); + const mail = captureMail(); + try { + await service.provisionAccount(team.uid, owner.id, { + username: `nomail_${Math.random().toString(36).slice(2, 9)}`, + }); + } finally { + mail.restore(); + } + expect(mail.sent).toHaveLength(0); + }); + + it('emails the fresh credential on re-issue, not the old one', async () => { + const { team } = await makeTeam(); + const username = `re_${Math.random().toString(36).slice(2, 9)}`; + const first = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + + const mail = captureMail(); + let again; + try { + again = await service.reissueCredential( + team.uid, + owner.id, + first.userId, + ); + } finally { + mail.restore(); + } + + expect(mail.sent).toHaveLength(1); + expect(mail.sent[0].html).toContain(again.temporaryPassword); + expect(mail.sent[0].html).not.toContain(first.temporaryPassword); + }); + it('tells a member their account was disabled', async () => { const { team } = await makeTeam(); const username = `dis_${Math.random().toString(36).slice(2, 9)}`; diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index bd391f56e0..7afe3c51be 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -788,7 +788,9 @@ export class TeamService extends PuterService { // Returned once; forced change on first use is what bounds it. const temporaryPassword = await this.#issueTemporaryPassword(user.id); - await this.#notifyUser(user, 'team_account_created', team); + await this.#notifyUser(user, 'team_account_created', team, { + temporary_password: temporaryPassword, + }); // Last: the seat is only chargeable once it exists and can be used. this.#emitBilling('team.account.created', { @@ -831,7 +833,9 @@ export class TeamService extends PuterService { }); const temporaryPassword = await this.#issueTemporaryPassword(targetUserId); - await this.#notifyUser(user, 'team_account_created', team); + await this.#notifyUser(user, 'team_account_created', team, { + temporary_password: temporaryPassword, + }); return { temporaryPassword }; } @@ -910,22 +914,19 @@ export class TeamService extends PuterService { return temporaryPassword; } - /** - * A notice about something the team did to a member's account. It carries - * no credential, so delivery is best effort -- nothing the caller did - * depends on it arriving, and an address the administrator supplied may not - * even reach its holder. - */ + /** Best effort: the admin also gets the credential in the API response. */ async #notifyUser( user: UserRow | null | undefined, template: EmailTemplateName, team: TeamRow, + vars: Record = {}, ): Promise { if (!this.clients.email || !user?.email) return; try { const sent = await this.clients.email.send(user.email, template, { username: user.username, team_name: team.name ?? 'Your team', + ...vars, }); // `sendRaw` returns null with no transport rather than throwing. if (sent === null) { diff --git a/src/docs/src/Teams/createMember.md b/src/docs/src/Teams/createMember.md index 691a0b8f93..112951901a 100644 --- a/src/docs/src/Teams/createMember.md +++ b/src/docs/src/Teams/createMember.md @@ -30,9 +30,9 @@ The username for the new account. Usernames come from the same pool as ordinary #### `options.email` (String) (optional) -Where the team's notices about this account are delivered. These accounts sign in by **username**, so an address is not needed and the form does not ask for one. +Where this account's notices are delivered. These accounts sign in by **username**, so an address is optional. -Supply it only if you want `team_account_created`, `team_account_disabled` and `team_password_reset` to reach the member; if you leave it out, those notices are simply not sent and the temporary password in the return value is the only delivery. If given, it must not already own an account. +Give one and the member is emailed their username and temporary password directly, and later notices (`team_account_disabled`, `team_password_reset`) reach them too. Leave it out and nothing is sent — the temporary password in the return value is then the only copy, so hand it over before you lose it. If given, it must not already own an account. The account is never asked to confirm the address — the team creating it is the trust anchor — so it can be used immediately either way. An account with no address is recoverable only through its team's owner, via `resetPassword`. diff --git a/src/docs/src/Teams/resendActivation.md b/src/docs/src/Teams/resendActivation.md index 7461deb52e..79c77a4d91 100644 --- a/src/docs/src/Teams/resendActivation.md +++ b/src/docs/src/Teams/resendActivation.md @@ -10,7 +10,7 @@ Issues a fresh one-time credential for an account that has never signed in, inva **It refuses once the account has been activated**, rejecting with `conflict`. After activation the member owns their own password, and an administrator able to replace it would be able to reach their files. An activated member resets their own password through the normal Puter flow. -The credential comes back once and is not retrievable afterwards. The member is emailed a notice that the account was set up; the notice carries no credential. +The credential comes back once and is not retrievable afterwards. If the account has an email address, the new credential is emailed to it as well; if it has none, the return value is the only copy. ## Syntax diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 2aa74977b4..186ee74d17 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -122,8 +122,10 @@ const renderAddAccount = () => { let h = '
    '; h += `

    ${i18n('teams_add_account')}

    `; h += `

    ${i18n('teams_add_account_hint')}

    `; + h += `

    ${i18n('teams_add_account_email_hint')}

    `; h += '
    '; h += ``; + h += ``; h += ``; h += '
    '; h += ''; @@ -310,11 +312,16 @@ const showCredential = ($el_window, username, temporaryPassword) => { const addAccount = async ($el_window) => { const username = $el_window.find(`${SECTION} .teams-new-username`).val().trim(); if ( ! username ) return; + // With an address the credential is emailed too; without it, only shown here. + const email = $el_window.find(`${SECTION} .teams-new-email`).val().trim(); const $button = $el_window.find(`${SECTION} .teams-add-btn`); $button.prop('disabled', true); try { - const created = await puter.teams.createMember(state.selected.uid, { username }); + const created = await puter.teams.createMember(state.selected.uid, { + username, + ...(email ? { email } : {}), + }); await refresh($el_window); showCredential($el_window, created.username, created.temporaryPassword); } catch (e) { diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 2d3b852fbb..82dd6dd3d0 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -529,6 +529,8 @@ const en = { teams_accounts: 'Accounts', teams_no_accounts: 'This team has no accounts yet.', teams_add_account: 'Add an account', + teams_email_optional: 'Email (optional)', + teams_add_account_email_hint: 'If you add an address, we email the username and temporary password to it. Otherwise the password below is the only copy.', teams_add_account_hint: 'Puter creates the account and gives you a one-time password to pass on. The username has to be free across all of Puter.', teams_member_kind: 'Kind', From 106978e714a5a7b74116d923b6c328e062c024dc Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 10 Sep 2026 16:50:49 -0400 Subject: [PATCH 04/27] feat: tell a seat which team its account belongs to A provisioned account had no way to know it was one. That matters: the team can reset its password and close it, which is exactly what the account-created email already warns about, and nothing in the product repeated it afterwards. `whoami` now carries `team: { uid, name }`. Two gates on it. Only user actors -- a seat's employer is no more an app's business than its phone number, which the same handler already withholds. And only where `teams_enabled` is on, so a deployment without teams is byte-identical. It rides whoami rather than a route of its own because the sidebar needs it at first paint. A `/teams/whoami` would add a request to every page load for every user, and almost none of them are seats. The lookup costs nothing either way: `getOrgSeat` is already cached, negative results included, precisely because almost nothing is a seat. `team_name` comes off a join the query already made. In the sidebar it sits under the Puter wordmark -- the conventional slot for workspace context -- as a muted second line, hidden when the sidebar collapses. Owners see nothing: they already know, and one may own several teams, so there would be no single name to show. The markup is a helper rather than another branch inside UIDashboard, matching how appGroups/credits/usageBudget were pulled out, so it can be tested without mocking the window stack. Falsified: dropping the `isUser` gate fails "withholds it from an app actor" and nothing else. 181 backend tests, 331 GUI/SDK tests, typecheck clean. --- extensions/whoami.test.ts | 76 ++++++++++++++++++++++ extensions/whoami.ts | 17 +++++ src/backend/stores/team/TeamStore.ts | 4 +- src/gui/src/UI/Dashboard/UIDashboard.js | 6 +- src/gui/src/UI/Dashboard/teamBadge.js | 35 ++++++++++ src/gui/src/UI/Dashboard/teamBadge.test.js | 36 ++++++++++ src/gui/src/css/dashboard.css | 19 +++++- src/gui/src/i18n/translations/en.js | 1 + 8 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/teamBadge.js create mode 100644 src/gui/src/UI/Dashboard/teamBadge.test.js diff --git a/extensions/whoami.test.ts b/extensions/whoami.test.ts index 8b54febfdd..984b7697fc 100644 --- a/extensions/whoami.test.ts +++ b/extensions/whoami.test.ts @@ -48,6 +48,7 @@ beforeAll(async () => { create_shortcut: true, payment_bypass: true, }, + teams_enabled: true, } as never); }); @@ -66,6 +67,81 @@ const seedUser = async () => { }; describe('whoami extension — handleWhoami', () => { + // The sidebar label needs this at boot, which is why it rides whoami + // rather than a call of its own. + describe('the team an account belongs to', () => { + // A seat is created, never adopted, so it must have no password. + const seedSeat = async () => { + const slug = Math.random().toString(36).slice(2, 8); + return server.stores.user.create({ + username: `wseat_${slug}`, + uuid: uuidv4(), + password: null, + email: null, + }); + }; + + const seatOf = async (teamName: string) => { + const owner = await seedUser(); + const seat = await seedSeat(); + const team = await server.stores.team.create({ + ownerUserId: owner.id as number, + name: teamName, + handle: `wt-${Math.random().toString(36).slice(2, 9)}`, + }); + await server.stores.team.addMember(team.uid, seat.id as number, { + orgOwned: true, + }); + return { seat, team }; + }; + + it('names the team for a seat', async () => { + const { seat, team } = await seatOf('Acme Corp'); + const { res, captured } = makeRes(); + + await runWithContext( + { actor: { user: { uuid: seat.uuid, id: seat.id as number } } }, + () => handleWhoami(makeReq(), res), + ); + + expect((captured.body as { team?: unknown }).team).toEqual({ + uid: team.uid, + name: 'Acme Corp', + }); + }); + + it('says nothing for an account that is not a seat', async () => { + const user = await seedUser(); + const { res, captured } = makeRes(); + + await runWithContext( + { actor: { user: { uuid: user.uuid, id: user.id as number } } }, + () => handleWhoami(makeReq(), res), + ); + + expect(captured.body).not.toHaveProperty('team'); + }); + + it('withholds it from an app actor', async () => { + // Same class as the phone number: a seat's employer is not an + // app's business. + const { seat } = await seatOf('Acme Corp'); + const { res, captured } = makeRes(); + + await runWithContext( + { + actor: { + user: { uuid: seat.uuid, id: seat.id as number }, + app: { uid: 'app-1' }, + }, + }, + () => handleWhoami(makeReq(), res), + ); + + expect(captured.body).not.toHaveProperty('team'); + }); + }); + it('returns 401 when no actor is on the context', async () => { const { res, captured } = makeRes(); diff --git a/extensions/whoami.ts b/extensions/whoami.ts index 049788b7d6..57ba963e9a 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -253,6 +253,23 @@ export const handleWhoami = async ( details.directories = directories; } + // The team an account belongs to, when it is one a team pays for. User + // actors only, and only where teams are on. + if (isUser && extension.config.teams_enabled === true) { + try { + const seat = await stores.team.getOrgSeat(user.id); + if (seat) { + details.team = { + uid: seat.team_uid, + name: seat.team_name ?? null, + }; + } + } catch (e) { + // Never fail whoami over this; the account still works without it. + console.warn('[whoami] team lookup failed:', (e as Error).message); + } + } + // Last activity const lastActivityTs = toUnixSeconds(user.last_activity_ts); if (lastActivityTs !== undefined) { diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 661ff25505..de4ee28b4c 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -53,6 +53,7 @@ export interface OrgSeatRow { uuid: string; username: string; team_uid: string; + team_name: string | null; owner_user_id: number; } @@ -626,7 +627,8 @@ export class TeamStore extends PuterStore { async #readOrgSeat(userId: number): Promise { const rows = (await this.clients.db.read( 'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' + - 'g.`uid` AS `team_uid`, g.`owner_user_id` ' + + 'g.`uid` AS `team_uid`, g.`name` AS `team_name`, ' + + 'g.`owner_user_id` ' + 'FROM `jct_user_group` ug ' + 'JOIN `user` u ON u.`id` = ug.`user_id` ' + 'JOIN `group` g ON g.`id` = ug.`group_id` ' + diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 559b0d9d2e..b00784d23b 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -53,6 +53,7 @@ import TabUsage from './TabUsage.js'; import TabAccount from './TabAccount.js'; import TabSecurity from './TabSecurity.js'; import TabTeams from './TabTeams.js'; +import teamBadgeHtml from './teamBadge.js'; // Registry of built-in tabs const builtinTabs = [ @@ -119,7 +120,10 @@ async function UIDashboard (options) { h += '
    '; // Sidebar header with logo and collapse toggle h += '
    '; - h += ``; + h += '
    '; + h += ``; + h += teamBadgeHtml(window.user); + h += '
    '; h += '