mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-24 22:26:42 +00:00
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:
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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, '<') };
|
||||
|
||||
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>');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user