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.
This commit is contained in:
Juan Castro
2026-09-11 13:14:09 -04:00
parent 06bdc26efd
commit 1efe086565
6 changed files with 103 additions and 1 deletions
@@ -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 () => {
@@ -182,10 +182,13 @@ export class TeamController extends PuterController {
})
async listMembers(req: Request, res: Response): Promise<void> {
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 } : {}),
});
+32
View File
@@ -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({
@@ -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 } : {}),
};
}
@@ -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 });
+1
View File
@@ -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.
*/
/**