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 +