feat: show a team's plan in the Teams tab, and let the owner change it

PUT-1796's UI half. The owner already manages accounts here, so the plan they
are billed for belongs on the same page rather than a tab away.

Pricing stays out of OSS. The card is drawn from whatever catalogue the server
returns, so this code knows no tier, no price and no payment provider -- a
deployment that sells nothing serves no catalogue and the card does not render.
`TabHome` already reads `/marketplace/subscriptions/current` the same way, so
OSS reading a prod-served endpoint is not a new idea here.

Two things are deliberately not offered rather than offered and broken. A tier
the server marks unavailable gets no button, because no Stripe price is
configured for it and buying would 422. And no button appears at all unless a
billing extension has set `window.team_billing_ui` -- the prices still render,
which is useful on its own, but nothing invites a click nobody can handle.
Checkout is Stripe's, so the button only dispatches `team-plan-purchase` with
the team and item id and lets the extension take it from there.

The markup is a helper rather than another branch in TabTeams, matching
teamBadge/credits/usageBudget, so it can be tested without the window stack.

Falsified: offering an unavailable tier fails "offers no button for a tier with
no configured price"; rendering buttons regardless of the extension fails
"offers no button without a billing extension to act on it".

342 GUI/SDK tests, 136 team backend tests, typecheck clean.
This commit is contained in:
Juan Castro
2026-09-10 17:42:51 -04:00
parent e886e86cb5
commit efe6ef09bd
5 changed files with 282 additions and 1 deletions
+49 -1
View File
@@ -19,6 +19,7 @@
import UIAlert from '../UIAlert.js';
import UIPrompt from '../UIPrompt.js';
import teamPlanHtml from './teamPlan.js';
import {
annotateMembers,
auditActionKey,
@@ -31,7 +32,7 @@ import {
const SECTION = '.dashboard-section-teams';
/** What the console last loaded, so a redraw needs no second round trip. */
let state = { status: 'loading', teams: [], selected: null, members: [], audit: [] };
let state = { status: 'loading', teams: [], selected: null, members: [], audit: [], plan: null };
/** In flight, so `init` and the initial-route `onActivate` don't both load. */
let loadPromise = null;
@@ -174,6 +175,12 @@ const renderMemberView = () => {
return h + renderAudit();
};
const renderPlan = () => teamPlanHtml({
plan: state.plan,
seats: state.members.filter(m => m.org_owned).length,
canBuy: window.team_billing_ui === true,
});
const renderDirectory = () => {
const on = state.selected?.directoryEnabled === true;
let h = '<div class="dashboard-card teams-panel">';
@@ -197,6 +204,7 @@ const renderOwnerView = () => {
h += `<button class="button teams-rename">${i18n('teams_rename')}</button>`;
h += '</div>';
h += renderPlan();
h += renderDirectory();
h += renderAddAccount();
h += renderMembers();
@@ -245,6 +253,35 @@ const loadSelected = async () => {
state.members = state.selected.isOwner
? await puter.teams.listMembers(state.selected.uid)
: [];
state.plan = state.selected.isOwner ? await loadPlan(state.selected.uid) : null;
};
/** Served by a billing extension; absent is a normal answer. */
const loadPlan = async (teamUid) => {
const get = async (path) => {
const resp = await fetch(`${window.api_origin}${path}`, {
headers: { Authorization: `Bearer ${puter.authToken}` },
});
return resp.ok ? resp.json() : null;
};
try {
const [cat, sub] = await Promise.all([
get('/marketplace/subscriptions/team-offerings'),
get(`/marketplace/teams/${encodeURIComponent(teamUid)}/subscription`),
]);
if ( ! cat ) return { status: 'unavailable', offerings: [], current: null };
const offerings = Array.isArray(cat.offerings) ? cat.offerings : [];
const entry = sub?.subscription ?? null;
return {
status: 'ready',
offerings,
current: entry
? { ...entry, name_en: offerings.find(o => o.tier === entry.tier)?.name_en }
: null,
};
} catch {
return { status: 'unavailable', offerings: [], current: null };
}
};
const load = async ($el_window) => {
@@ -481,6 +518,17 @@ const TabTeams = {
$el_window.on('click', `${SECTION} .teams-create`, () => createTeam($el_window));
$el_window.on('click', `${SECTION} .teams-rename`, () => renameTeam($el_window));
$el_window.on('click', `${SECTION} .teams-delete`, () => deleteTeam($el_window));
// Checkout is the billing extension's job; this only says what was asked for.
$el_window.on('click', `${SECTION} .teams-plan-buy`, function () {
if ( ! state.selected ) return;
window.dispatchEvent(new CustomEvent('team-plan-purchase', {
detail: {
teamUid: state.selected.uid,
itemId: $(this).attr('data-item-id'),
onDone: () => refresh($el_window),
},
}));
});
$el_window.on('click', `${SECTION} .teams-reset`, function () {
reissueCredential($el_window, $(this).attr('data-username'));
});
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The team's plan card, drawn from whatever catalogue the server returned. No
* tier, price or payment provider is known here.
*
* @param {object} args `{ plan, seats, canBuy }`
* @returns {string} markup, or '' when there is no catalogue
*/
export const teamPlanHtml = ({ plan, seats = 0, canBuy = false } = {}) => {
if ( plan?.status !== 'ready' ) return '';
const current = plan.current ?? null;
const offerings = Array.isArray(plan.offerings) ? plan.offerings : [];
let h = '<div class="dashboard-card teams-panel teams-plan">';
h += `<h3>${i18n('teams_plan')}</h3>`;
if ( current ) {
const key = seats === 1 ? 'teams_plan_current_one' : 'teams_plan_current';
h += `<p class="teams-panel-hint">${i18n(key, {
plan: current.name_en || current.tier,
seats,
})}</p>`;
if ( current.status && current.status !== 'active' ) {
h += `<p class="teams-plan-status">${window.html_encode(current.status)}</p>`;
}
} else {
h += `<p class="teams-panel-hint">${i18n('teams_plan_none')}</p>`;
}
if ( offerings.length ) {
h += '<ul class="teams-plan-list">';
for ( const o of offerings ) {
h += '<li class="teams-plan-option">';
h += `<span class="teams-plan-name">${window.html_encode(o.name_en || o.tier)}</span>`;
h += `<span class="teams-plan-price">${i18n('teams_plan_per_seat', {
amount: o.amountPerSeat,
currency: o.currency,
})}</span>`;
if ( current?.tier === o.tier ) {
h += `<span class="teams-plan-badge">${i18n('teams_plan_current_badge')}</span>`;
} else if ( ! o.available ) {
// The server says no price is configured; buying would 422.
h += `<span class="teams-plan-badge">${i18n('teams_plan_unavailable')}</span>`;
} else if ( canBuy ) {
const label = current ? i18n('teams_plan_switch') : i18n('teams_plan_buy');
h += `<button class="button teams-plan-buy" data-item-id="${window.html_encode(o.itemId)}">${label}</button>`;
}
h += '</li>';
}
h += '</ul>';
}
h += '</div>';
return h;
};
export default teamPlanHtml;
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
globalThis.i18n = (key, args) =>
args && !Array.isArray(args)
? `${key}(${Object.entries(args).map(([k, v]) => `${k}=${v}`).join(',')})`
: key;
globalThis.window = { html_encode: (v) => String(v).replace(/</g, '&lt;') };
const { teamPlanHtml } = await import('./teamPlan.js');
const offering = (over = {}) => ({
itemId: 'puter-team-basic',
tier: 'team-basic',
name_en: 'Team Basic',
amountPerSeat: 10,
currency: 'USD',
available: true,
...over,
});
const ready = (over = {}) => ({
status: 'ready',
offerings: [offering()],
current: null,
...over,
});
describe('the team plan card', () => {
it('draws nothing when no catalogue came back', () => {
// A deployment that sells nothing serves no catalogue.
for (const plan of [null, undefined, { status: 'unavailable' }, {}]) {
expect(teamPlanHtml({ plan })).toBe('');
}
});
it('says the team is on the free plan when it has bought nothing', () => {
const h = teamPlanHtml({ plan: ready(), canBuy: true });
expect(h).toContain('teams_plan_none');
});
it('names the current plan and the accounts billed', () => {
const h = teamPlanHtml({
plan: ready({ current: { tier: 'team-basic', name_en: 'Team Basic', status: 'active' } }),
seats: 3,
});
expect(h).toContain('plan=Team Basic');
expect(h).toContain('seats=3');
});
it('uses the singular for one account', () => {
const h = teamPlanHtml({
plan: ready({ current: { tier: 'team-basic', status: 'active' } }),
seats: 1,
});
expect(h).toContain('teams_plan_current_one');
expect(h).not.toContain('teams_plan_current(');
});
it('surfaces a status that is not active, so dunning is visible', () => {
const h = teamPlanHtml({
plan: ready({ current: { tier: 'team-basic', status: 'past_due' } }),
});
expect(h).toContain('past_due');
});
it('offers no button for a tier with no configured price', () => {
// Buying one 422s, so the button must not be there to press.
const h = teamPlanHtml({
plan: ready({ offerings: [offering({ available: false })] }),
canBuy: true,
});
expect(h).toContain('teams_plan_unavailable');
expect(h).not.toContain('teams-plan-buy');
});
it('offers no button without a billing extension to act on it', () => {
const h = teamPlanHtml({ plan: ready(), canBuy: false });
expect(h).not.toContain('teams-plan-buy');
// The prices still render; only the action is missing.
expect(h).toContain('Team Basic');
});
it('marks the tier the team already holds instead of offering it again', () => {
const h = teamPlanHtml({
plan: ready({ current: { tier: 'team-basic', status: 'active' } }),
canBuy: true,
});
expect(h).toContain('teams_plan_current_badge');
expect(h).not.toContain('teams-plan-buy');
});
it('carries the item id the purchase needs', () => {
const h = teamPlanHtml({ plan: ready(), canBuy: true });
expect(h).toContain('data-item-id="puter-team-basic"');
expect(h).toContain('teams_plan_buy');
});
it('says switch, not choose, once a plan is held', () => {
const h = teamPlanHtml({
plan: ready({
offerings: [offering(), offering({ itemId: 'puter-team-pro', tier: 'team-pro', name_en: 'Team Pro' })],
current: { tier: 'team-basic', status: 'active' },
}),
canBuy: true,
});
expect(h).toContain('teams_plan_switch');
});
it('encodes a name the server supplied', () => {
const h = teamPlanHtml({
plan: ready({ offerings: [offering({ name_en: '<script>x</script>' })] }),
});
expect(h).not.toContain('<script>');
});
});
+35
View File
@@ -7955,6 +7955,41 @@ body.dashboard-mode .notifications-close-all {
margin-bottom: 0;
}
.teams-plan-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.teams-plan-option {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--dashboard-border);
border-radius: 8px;
}
.teams-plan-name {
font-weight: 500;
color: var(--dashboard-text);
}
.teams-plan-price {
flex: 1;
font-size: 13px;
color: var(--dashboard-text-hint);
}
.teams-plan-badge,
.teams-plan-status {
font-size: 12px;
color: var(--dashboard-text-muted);
}
.teams-card {
display: flex;
align-items: center;
+9
View File
@@ -529,6 +529,15 @@ const en = {
teams_accounts: 'Accounts',
teams_no_accounts: 'This team has no accounts yet.',
teams_account_of: 'This account belongs to %%',
teams_plan: 'Plan',
teams_plan_none: 'This team is on the free plan. Members get a reduced allowance.',
teams_plan_current: 'On {{plan}}, billed for {{seats}} accounts.',
teams_plan_current_one: 'On {{plan}}, billed for 1 account.',
teams_plan_current_badge: 'Current',
teams_plan_unavailable: 'Unavailable',
teams_plan_per_seat: '{{amount}} {{currency}} per account, per month',
teams_plan_buy: 'Choose',
teams_plan_switch: 'Switch',
teams_add_account: 'Add an account',
teams_email_optional: 'Email (optional)',
teams_add_account_email_hint: 'If you add an address, we email the username and temporary password to it. Otherwise the password below is the only copy.',