mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-21 12:46:00 +00:00
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:
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user