From 480d6bc3b55987b63616cd000495cff4601873f8 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 11 Sep 2026 14:09:07 -0400 Subject: [PATCH] feat: give a team seat the account surface that is actually its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provisioned account could rename itself, delete itself, and see a Billing tab for a subscription it does not hold — all of it the team's, not the account's. Each is now refused server-side and dropped from the UI, keyed on one predicate: whoami reports a team only for an org-owned seat, and the owner joined their own team. The Teams tab showed a seat nothing but its own audit rows, so a member could not see who else was on the team they were told they shared it with. It now lists them; listMembers was already membership-gated and already withholds from a member what is not theirs. The dashboard share modal had no way to reach a team, though the desktop dialog has had one since team sharing shipped and the SDK has always taken `{ team }`. Same control, same copy, same helper. The access list needed a team bucket to go with it: a team share names no holder, so the aggregate dropped it and a team you had just shared with vanished. --- .../controllers/auth/AuthController.test.ts | 48 ++++++++ .../controllers/auth/AuthController.ts | 20 ++++ src/gui/src/UI/Dashboard/TabAccount.js | 39 ++++--- src/gui/src/UI/Dashboard/TabTeams.js | 31 +++++- src/gui/src/UI/Dashboard/UIShareModal.js | 56 +++++++++- src/gui/src/UI/Dashboard/orgSeat.js | 38 +++++++ src/gui/src/UI/Dashboard/orgSeat.test.js | 29 +++++ src/gui/src/UI/Dashboard/shareAggregate.js | 41 +++++-- .../src/UI/Dashboard/shareAggregate.test.js | 48 ++++++++ src/gui/src/css/dashboard.css | 103 +++++++++++++++++- src/gui/src/i18n/translations/en.js | 5 + 11 files changed, 429 insertions(+), 29 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/orgSeat.js create mode 100644 src/gui/src/UI/Dashboard/orgSeat.test.js diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 247e505cd..6ddef1732 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -5253,6 +5253,32 @@ describe('AuthController user-protected mutations (validation paths)', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); + it('change-username: 403 for an account its team provisioned', async () => { + // The console lists members by username and the audit log records them + // by it; a self-service rename would desync both. + const { user: owner } = await makeUserAndActor(); + const { user: seat, actor } = await makeUserAndActor(); + const team = await server.stores.team.create({ + ownerUserId: owner.id, + name: 'Acme', + }); + // addMember refuses to adopt an account that has one. + await server.stores.user.update(seat.id, { password: null }); + await server.stores.team.addMember(team.uid, seat.id, { + orgOwned: true, + }); + + await expect( + controller.handleChangeUsername( + makeReq({ new_username: `r_${uniq()}` }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + + const after = await server.stores.user.getById(seat.id, { force: true }); + expect(after!.username).toBe(seat.username); + }); + it('change-username: persists the rename and emits user.username-changed', async () => { const { user, actor } = await makeUserAndActor(); const newUsername = `r_${uniq()}`; @@ -6487,6 +6513,28 @@ describe('AuthController.handleDeleteOwnUser', () => { expect(after).toBeFalsy(); }); + it('refuses, and keeps the row, for an account its team provisioned', async () => { + // The team is billed for the seat and closing it is theirs to do, from + // the console that keeps the audit trail. + const { user: owner } = await makeUserAndActor(); + const { user: seat, actor } = await makeUserAndActor(); + const team = await server.stores.team.create({ + ownerUserId: owner.id, + name: 'Acme', + }); + await server.stores.user.update(seat.id, { password: null }); + await server.stores.team.addMember(team.uid, seat.id, { + orgOwned: true, + }); + + await expect( + controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()), + ).rejects.toMatchObject({ statusCode: 403 }); + + const after = await server.stores.user.getById(seat.id, { force: true }); + expect(after).toBeTruthy(); + }); + it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => { // `stripe_customer_id` ships in the MySQL/Postgres migrations but not // the sqlite ones the test harness runs — add it so the delete path diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 78a362686..973b7c237 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -2551,6 +2551,17 @@ export class AuthController extends PuterController { } async handleChangeUsername(req: Request, res: Response): Promise { + // A provisioned account's name belongs to the team that made it: the + // console lists its members by username and the audit log records them + // by username, so a self-service rename would desync both. + if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) { + throw new HttpError( + 403, + 'Your team set this username. Ask a team admin to change it.', + { legacyCode: 'forbidden' }, + ); + } + const { new_username } = req.body ?? {}; if (!new_username || typeof new_username !== 'string') { throw new HttpError(400, '`new_username` is required', { @@ -4374,6 +4385,15 @@ export class AuthController extends PuterController { async handleDeleteOwnUser(req: Request, res: Response): Promise { const userId = req.actor!.user.id!; + // The team owns the account and is billed for it; only they may close + // it, through the console that keeps the audit trail. + if (await this.stores.team.getOrgSeat(userId)) { + throw new HttpError( + 403, + 'Your team owns this account. Ask a team admin to remove it.', + { legacyCode: 'forbidden' }, + ); + } res.clearCookie(this.config.cookie_name ?? 'puter_token'); res.clearCookie('puter_token_v2'); res.clearCookie('puter_revalidation'); diff --git a/src/gui/src/UI/Dashboard/TabAccount.js b/src/gui/src/UI/Dashboard/TabAccount.js index 797d2dbf8..42928e0fa 100644 --- a/src/gui/src/UI/Dashboard/TabAccount.js +++ b/src/gui/src/UI/Dashboard/TabAccount.js @@ -24,6 +24,7 @@ import UIWindowConfirmUserDeletion from '../Settings/UIWindowConfirmUserDeletion import UIWindowCopyToken from '../UIWindowCopyToken.js'; import UIWindow from '../UIWindow.js'; import UIProfilePictureCropModal from './UIProfilePictureCropModal.js'; +import { isOrgSeat, orgSeatTeamName } from './orgSeat.js'; const TabAccount = { id: 'account', @@ -67,7 +68,17 @@ const TabAccount = { h += `${html_encode(window.user.username)}`; h += ''; h += ''; - h += ``; + // A seat's name is the team's; the console and audit log key on it. + if ( isOrgSeat(window.user) ) { + const team = orgSeatTeamName(window.user); + h += `${ + team + ? i18n('username_set_by_team', { team }) + : i18n('username_set_by_team_generic') + }`; + } else { + h += ``; + } h += ''; // Password card (only for non-temp users) @@ -124,18 +135,20 @@ const TabAccount = { } h += ''; - // Danger zone - h += '
'; - h += '
'; - h += '
'; - h += '
'; - h += `${i18n('delete_account')}`; - h += 'Permanently delete your account and all associated data. This action cannot be undone.'; - h += '
'; - h += '
'; - h += ``; - h += '
'; - h += '
'; + // Danger zone. A seat has none: the team owns the account. + if ( ! isOrgSeat(window.user) ) { + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += `${i18n('delete_account')}`; + h += 'Permanently delete your account and all associated data. This action cannot be undone.'; + h += '
'; + h += '
'; + h += ``; + h += '
'; + h += '
'; + } h += ''; // end settings-grid diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 5269a950b..18d0a70ae 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -206,13 +206,34 @@ const renderAudit = () => { return h; }; -/** What a member sees: their own entries, and nothing administrative. */ +/** Colleagues, by name. No state, no dates, no actions — none are theirs. */ +const renderRoster = () => { + let h = '
'; + h += `

${i18n('teams_roster')}

`; + h += `

${i18n('teams_roster_hint')}

`; + if ( state.members.length === 0 ) { + h += `

${i18n('teams_roster_empty')}

`; + } else { + h += '
    '; + for ( const member of sortMembers(state.members) ) { + const you = member.username === window.user?.username; + h += `
  • ${html_encode(member.username)}`; + if ( you ) h += ` (${i18n('share_you')})`; + h += '
  • '; + } + h += '
'; + } + h += '
'; + return h; +}; + +/** What a member sees: who else is here, their own entries, nothing admin. */ const renderMemberView = () => { let h = '
'; h += `

${i18n('teams_your_record')}

`; h += `

${i18n('teams_your_record_hint', { team: teamName(state.selected) })}

`; h += '
'; - return h + renderAudit(); + return h + renderRoster() + renderAudit(); }; const renderDirectory = () => { @@ -283,9 +304,9 @@ const loadSelected = async () => { state.audit = state.selected.isOwner ? await puter.teams.listAudit(state.selected.uid) : await puter.teams.listOwnAudit(state.selected.uid); - state.members = state.selected.isOwner - ? await puter.teams.listMembers(state.selected.uid) - : []; + // Members too: the roster is theirs to see, and the controller already + // withholds from them what is not. + state.members = await puter.teams.listMembers(state.selected.uid); state.plan = state.selected.isOwner ? await loadPlan(state.selected.uid) : null; }; diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js index 9a0b6ae92..31fa00be1 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.js @@ -31,6 +31,7 @@ import { } from '../../helpers/sharedBadge.js'; import { share_outcome } from '../../helpers/shareOutcome.js'; import { aggregateOwners, aggregateShares, missingPathsFor } from './shareAggregate.js'; +import { team_label, teams_for_sharing } from '../../helpers/shareTeams.js'; const { html_encode } = window; @@ -130,6 +131,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // Nothing to share: an empty selection is a caller's mistake, not a dialog. if ( total === 0 ) return { close: () => {} }; const items_list_id = `share-modal-items-${++modal_seq}`; + const teams_select_id = `share-modal-teams-${modal_seq}`; // The header names the one item, or the size of the pile with the names // folded into an expandable list below it. @@ -177,6 +179,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse +