diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js index b1a701f19..576c1c090 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.js +++ b/src/gui/src/UI/Dashboard/TabTeams.js @@ -26,6 +26,7 @@ import { canDeleteAccount, auditReasonKey, membersBillingSummary, + memberPlanLabel, sortMembers, } from './teamsConsole.js'; @@ -72,6 +73,12 @@ const renderTeamPicker = () => { return h; }; +const planCell = (member) => { + const label = memberPlanLabel(member, state.plan); + if ( label.kind === 'tier' ) return html_encode(label.name); + return i18n(`teams_member_plan_${label.kind}`); +}; + const renderMemberRow = (member) => { const username = html_encode(member.username); let h = ``; @@ -79,6 +86,7 @@ const renderMemberRow = (member) => { h += `${i18n(member.orgOwned ? 'teams_member_provisioned' : 'teams_member_joined')}`; h += `${i18n(member.disabled ? 'teams_member_state_disabled' : 'teams_member_state_active')}`; h += `${html_encode(dateText(member.createdAt))}`; + h += `${planCell(member)}`; h += ''; if ( member.orgOwned ) { h += ``; @@ -110,6 +118,7 @@ const renderMembers = () => { h += `${i18n('teams_member_kind')}`; h += `${i18n('teams_member_state')}`; h += `${i18n('teams_member_since')}`; + h += `${i18n('teams_member_plan')}`; h += `${i18n('teams_member_actions')}`; h += ''; for ( const member of sortMembers(annotated) ) h += renderMemberRow(member); diff --git a/src/gui/src/UI/Dashboard/teamsConsole.js b/src/gui/src/UI/Dashboard/teamsConsole.js index c55fc9553..ba91e1d27 100644 --- a/src/gui/src/UI/Dashboard/teamsConsole.js +++ b/src/gui/src/UI/Dashboard/teamsConsole.js @@ -116,6 +116,25 @@ export function membersBillingSummary (annotated) { return summary; } +/** + * What plan a row in the accounts table is on. + * + * `payer` is the owner, who keeps their personal plan. A suspended seat is + * `not_billed` -- it stops costing a per-account charge. Everyone else follows + * the team: its tier if it bought one, otherwise the reduced free allowance. + * + * @param {{ orgOwned: boolean, disabled: boolean }} member + * @param {{ current: { tier: string, name_en?: string } | null } | null} plan + * @returns {{ kind: 'payer'|'not_billed'|'free'|'tier', name?: string }} + */ +export function memberPlanLabel (member, plan) { + if ( ! member?.orgOwned ) return { kind: 'payer' }; + if ( member.disabled ) return { kind: 'not_billed' }; + const current = plan?.current; + if ( ! current ) return { kind: 'free' }; + return { kind: 'tier', name: current.name_en || current.tier }; +} + /** * The i18n key for an audit action, or `null` for one this build does not know * about — a new backend action must show as itself rather than as nothing. diff --git a/src/gui/src/UI/Dashboard/teamsConsole.test.js b/src/gui/src/UI/Dashboard/teamsConsole.test.js index 2e0d830df..a061b5a76 100644 --- a/src/gui/src/UI/Dashboard/teamsConsole.test.js +++ b/src/gui/src/UI/Dashboard/teamsConsole.test.js @@ -6,6 +6,7 @@ import { auditReasonKey, memberStatesFromAudit, membersBillingSummary, + memberPlanLabel, sortMembers, } from './teamsConsole.js'; @@ -165,3 +166,35 @@ describe('sortMembers', () => { expect(annotated.map(m => m.username)).toEqual(['zoe', 'ann']); }); }); + +describe('what plan a row in the accounts table shows', () => { + const paid = { current: { tier: 'team-basic', name_en: 'Team Basic' } }; + + it('names the team tier for a billed seat', () => { + expect(memberPlanLabel({ orgOwned: true, disabled: false }, paid)) + .toEqual({ kind: 'tier', name: 'Team Basic' }); + }); + + it('falls back to the tier id when the catalogue has no name', () => { + expect(memberPlanLabel({ orgOwned: true }, { current: { tier: 'team-pro' } })) + .toEqual({ kind: 'tier', name: 'team-pro' }); + }); + + it('says the owner is the payer, not a seat', () => { + // They keep their own personal plan; the team tier is not theirs. + expect(memberPlanLabel({ orgOwned: false }, paid)).toEqual({ kind: 'payer' }); + }); + + it('says a suspended seat is not billed', () => { + // It stops costing a per-account charge, which is what the card counts. + expect(memberPlanLabel({ orgOwned: true, disabled: true }, paid)) + .toEqual({ kind: 'not_billed' }); + }); + + it('says free when the team bought nothing', () => { + for (const plan of [null, undefined, { current: null }]) { + expect(memberPlanLabel({ orgOwned: true, disabled: false }, plan)) + .toEqual({ kind: 'free' }); + } + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 3c8f4e217..e5980070d 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -543,6 +543,10 @@ const en = { 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.', teams_add_account_hint: 'Puter creates the account and gives you a one-time password to pass on. The username has to be free across all of Puter.', + teams_member_plan: 'Plan', + teams_member_plan_payer: '— payer', + teams_member_plan_not_billed: 'Not billed', + teams_member_plan_free: 'Free', teams_member_kind: 'Kind', teams_member_state: 'State', teams_member_since: 'Added',