From efe6ef09bd2962dc0b2ba79a5be68e1ee9774cd7 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 10 Sep 2026 17:42:51 -0400 Subject: [PATCH] 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. --- src/gui/src/UI/Dashboard/TabTeams.js | 50 +++++++++- src/gui/src/UI/Dashboard/teamPlan.js | 74 ++++++++++++++ src/gui/src/UI/Dashboard/teamPlan.test.js | 115 ++++++++++++++++++++++ src/gui/src/css/dashboard.css | 35 +++++++ src/gui/src/i18n/translations/en.js | 9 ++ 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/gui/src/UI/Dashboard/teamPlan.js create mode 100644 src/gui/src/UI/Dashboard/teamPlan.test.js diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index 186ee74d1..42f3db4a3 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -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 = '
'; @@ -197,6 +204,7 @@ const renderOwnerView = () => { h += ``; h += '
'; + 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')); }); diff --git a/src/gui/src/UI/Dashboard/teamPlan.js b/src/gui/src/UI/Dashboard/teamPlan.js new file mode 100644 index 000000000..d3bc6fa34 --- /dev/null +++ b/src/gui/src/UI/Dashboard/teamPlan.js @@ -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 . + */ + +/** + * 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 = '
'; + h += `

${i18n('teams_plan')}

`; + + if ( current ) { + const key = seats === 1 ? 'teams_plan_current_one' : 'teams_plan_current'; + h += `

${i18n(key, { + plan: current.name_en || current.tier, + seats, + })}

`; + if ( current.status && current.status !== 'active' ) { + h += `

${window.html_encode(current.status)}

`; + } + } else { + h += `

${i18n('teams_plan_none')}

`; + } + + if ( offerings.length ) { + h += '
    '; + for ( const o of offerings ) { + h += '
  • '; + h += `${window.html_encode(o.name_en || o.tier)}`; + h += `${i18n('teams_plan_per_seat', { + amount: o.amountPerSeat, + currency: o.currency, + })}`; + if ( current?.tier === o.tier ) { + h += `${i18n('teams_plan_current_badge')}`; + } else if ( ! o.available ) { + // The server says no price is configured; buying would 422. + h += `${i18n('teams_plan_unavailable')}`; + } else if ( canBuy ) { + const label = current ? i18n('teams_plan_switch') : i18n('teams_plan_buy'); + h += ``; + } + h += '
  • '; + } + h += '
'; + } + h += '
'; + return h; +}; + +export default teamPlanHtml; diff --git a/src/gui/src/UI/Dashboard/teamPlan.test.js b/src/gui/src/UI/Dashboard/teamPlan.test.js new file mode 100644 index 000000000..5f00a27e8 --- /dev/null +++ b/src/gui/src/UI/Dashboard/teamPlan.test.js @@ -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(/ ({ + 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: '' })] }), + }); + expect(h).not.toContain('