From 1efe0865657d84d7e06342e56e117548af44dcac Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 11 Sep 2026 13:14:09 -0400 Subject: [PATCH] fix: give the owner the seat uuid the plan action is keyed on The per-account subscription button did nothing. `listMembers` never returned a member's uuid, the SDK's `toMember` dropped it, and the Plan column read `seatTiers[undefined]`, so every seat rendered as Free and the action dispatched with no seat. The uuid is owner-only: it is what billing keys a seat's plan on, and one member has no business identifying another. --- .../team/TeamController.http.test.ts | 46 +++++++++++++++++++ .../controllers/team/TeamController.ts | 6 ++- src/gui/src/UI/Dashboard/TabTeams.js | 32 +++++++++++++ src/puter-js/src/modules/teams/lib/shapes.js | 2 + src/puter-js/src/modules/teams/teams.test.js | 17 +++++++ src/puter-js/src/modules/teams/types.js | 1 + 6 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts index 71891ecd0..77b3db28f 100644 --- a/src/backend/controllers/team/TeamController.http.test.ts +++ b/src/backend/controllers/team/TeamController.http.test.ts @@ -235,6 +235,52 @@ describe('team endpoints over HTTP', () => { expect(member?.org_owned).toBe(true); }); + it('gives seat uuids to the owner and to nobody else', async () => { + // Built through the service, not the wire: provisioning shares a + // rate-limit budget with the seat tests below, and one more HTTP + // provision here 429s them. + const owner = (await env.server.stores.user.getByUsername( + env.users.user.username, + ))!; + const slug0 = Math.random().toString(36).slice(2, 9); + const team = await env.server.services.team.createTeam(owner.id, { + name: 'Acme', + handle: `uuidt-${slug0}`, + }); + const memberUsername = `seat_${slug0}`; + await env.server.services.team.provisionAccount(team.uid, owner.id, { + username: memberUsername, + }); + + const owned = (await ( + await call('GET', `/teams/${team.uid}/members`, env.users.user.token) + ).json()) as { items: { username: string; uuid?: string }[] }; + const seat = owned.items.find((m) => m.username === memberUsername); + // Billing keys a seat's plan on this, so the owner cannot act without it. + expect(seat?.uuid).toEqual(expect.any(String)); + + // A throwaway member, not the shared fixture: joining a team is + // permanent and would follow `other` into every later test. + const slug = Math.random().toString(36).slice(2, 9); + const joiner = await env.server.stores.user.create({ + username: `joiner_${slug}`, + uuid: crypto.randomUUID(), + password: 'hashed', + email: `joiner_${slug}@test.local`, + }); + await env.server.stores.team.addMember(team.uid, joiner.id, { + orgOwned: false, + }); + const { token } = + await env.server.services.auth.createSessionToken(joiner); + + const seen = (await ( + await call('GET', `/teams/${team.uid}/members`, token) + ).json()) as { items: { uuid?: string }[] }; + expect(seen.items.length).toBeGreaterThan(0); + for (const m of seen.items) expect(m.uuid).toBeUndefined(); + }); + // -- provisioning over the wire ----------------------------------- it('never returns the activation link to the administrator', async () => { diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts index 59d547653..af54dad94 100644 --- a/src/backend/controllers/team/TeamController.ts +++ b/src/backend/controllers/team/TeamController.ts @@ -182,10 +182,13 @@ export class TeamController extends PuterController { }) async listMembers(req: Request, res: Response): Promise { const userId = this.#requireUserId(req); - await this.services.team.requireMembership( + const team = await this.services.team.requireMembership( this.#param(req, 'uid'), userId, ); + // Only the owner: the uuid is what billing keys a seat's plan on, and + // one member has no business identifying another. + const isOwner = team.owner_user_id === userId; const page = await this.stores.team.listMembers( this.#param(req, 'uid'), @@ -202,6 +205,7 @@ export class TeamController extends PuterController { username: m.username, org_owned: Number(m.org_owned) === 1, created_at: m.created_at, + ...(isOwner ? { uuid: m.uuid } : {}), })), ...(page.cursor ? { cursor: page.cursor } : {}), }); diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index d18cc1058..5269a950b 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -330,6 +330,38 @@ const changeSeatPlan = ($el_window, username, uuid) => { })); }; +const load = async ($el_window) => { + // The API can be on while the interface is not; same effect as no route. + if ( ! window.teams_ui ) { + state.status = 'unavailable'; + setTabVisible($el_window, false); + return paint($el_window); + } + try { + const teams = await puter.teams.list(); + state.teams = teams; + state.selected = teams.find(t => t.uid === state.selected?.uid) ?? teams[0] ?? null; + await loadSelected(); + state.status = 'ready'; + setTabVisible($el_window, true); + } catch (e) { + // A deployment with teams off registers no `/teams` route, so the + // 404 is the feature gate rather than a failure worth reporting. + state.status = e?.code === 'not_found' ? 'unavailable' : 'error'; + state.teams = []; + state.selected = null; + setTabVisible($el_window, false); + } + paint($el_window); +}; + +const refresh = ($el_window) => { + if ( ! loadPromise ) { + loadPromise = load($el_window).finally(() => { loadPromise = null; }); + } + return loadPromise; +}; + // -- Actions -------------------------------------------------------------- const showError = ($el_window, e) => UIAlert({ diff --git a/src/puter-js/src/modules/teams/lib/shapes.js b/src/puter-js/src/modules/teams/lib/shapes.js index 65f2f6bd0..57e9b820b 100644 --- a/src/puter-js/src/modules/teams/lib/shapes.js +++ b/src/puter-js/src/modules/teams/lib/shapes.js @@ -41,6 +41,8 @@ export function toMember (row) { username: /** @type {string} */ (row.username), orgOwned: row.org_owned === true, createdAt: /** @type {string} */ (row.created_at), + // Owners only, and what billing keys a seat's plan on. + ...(typeof row.uuid === 'string' ? { uuid: row.uuid } : {}), }; } diff --git a/src/puter-js/src/modules/teams/teams.test.js b/src/puter-js/src/modules/teams/teams.test.js index d2f28bbc8..5336b491a 100644 --- a/src/puter-js/src/modules/teams/teams.test.js +++ b/src/puter-js/src/modules/teams/teams.test.js @@ -115,6 +115,23 @@ describe('list forms', () => { expect(mockReq).toHaveBeenCalledTimes(2); }); + it('keeps the seat uuid, which the plan action is keyed on', async () => { + routes({ 'GET /teams/t-1/members': { items: [ + { username: 'ann', org_owned: true, created_at: 'x', uuid: 'u-1' }, + ] } }); + const [member] = await teams.listMembers('t-1'); + expect(member.uuid).toBe('u-1'); + }); + + it('omits it entirely when the server withheld it', async () => { + // A non-owner gets no uuids; `undefined` must not become a key. + routes({ 'GET /teams/t-1/members': { items: [ + { username: 'ann', org_owned: true, created_at: 'x' }, + ] } }); + const [member] = await teams.listMembers('t-1'); + expect('uuid' in member).toBe(false); + }); + it('returns the page envelope when a cursor is passed', async () => { paged(); const result = await teams.listMembers('t-1', { cursor: null }); diff --git a/src/puter-js/src/modules/teams/types.js b/src/puter-js/src/modules/teams/types.js index bbacb5cc6..8869d26fc 100644 --- a/src/puter-js/src/modules/teams/types.js +++ b/src/puter-js/src/modules/teams/types.js @@ -48,6 +48,7 @@ * @property {boolean} orgOwned Whether the team provisioned and pays for this account, as opposed * to a pre-existing account that joined it. * @property {string} createdAt When the account joined the team, in `YYYY-MM-DDTHH:MM:SSZ` format. + * @property {string} [uuid] Present for the team owner only; billing keys a seat's plan on it. */ /**