feat: the team seat experience β no email required, forced password change, team label, and plan-based limits (PUT-1792) - #3846
Conversation
β¦rm one PUT-1792. Two separate problems, both from treating a provisioned account like a self-registered one. `email` was required, so an admin creating ten seats had to invent ten addresses and then keep track of ten uniqueness constraints -- for accounts that sign in by username and never use the address. It is now optional at every layer, and the add-account form does not ask for it at all: username is the only thing a seat needs. `requires_email_confirmation` was set to true, with the reasoning that an admin-supplied address is unverified. True, but `requireVerifiedAccount` turns away on exactly `requires_email_confirmation && !email_confirmed`, so a freshly created seat was asked to confirm an address it may not hold and could not use the product until it did. The team creating the account is the trust anchor, not the mailbox, so this is now false either way. An address is still accepted and still stored when given, because the notices are worth delivering. `#notifyUser` already returned early on a missing address, so `team_account_created`, `team_account_disabled`, `team_password_reset` and `team_closed` degrade quietly with no new branching -- the temporary password is in the API response, which is the documented delivery. `idx_user_owned_email` is partial and skips password-null rows, so omitting the address sidesteps it rather than creating a collision surface. Two seats with no address do not conflict, and there is a test for it. Docs now say an emailless seat is recoverable only through its team's owner. That falls out of the design rather than being a limitation of this change, but it should be written down rather than discovered. Falsified: putting `requires_email_confirmation: true` back fails "never demands confirmation, with or without an address" with `expected true to be false`, and nothing else. 164 team tests, 40 SDK tests, typecheck clean.
Coverage Report
File Coverage |
The backend already refused every route with `password_change_required` until a provisioned account replaced the password its administrator chose, and `/user-protected/change-password` was already exempt so the account could act. The client half was missing entirely: nothing in the GUI referenced that code, so a seat signed in and then failed at everything with no prompt and no way out. Two gaps, both closed here. The flag never reached the client. Neither the login response nor `/whoami` carried `requires_password_change`, so the GUI could not have known even if it wanted to. It ships from both now, alongside the other three verification flags the whoami extension already describes as "the flags the GUI acts on". There was no window to show. `UIWindowChangePassword` is a settings dialog -- closable, and it never resolves on success -- so it cannot act as a gate. `UIWindowPasswordChangeRequired` mirrors the existing `*Required` windows: it resolves true only once the change lands, and `initgui` loops on it. It runs last in the boot chain, matching the server order in assertVerifiedAccount. It refuses a new password equal to the current one. Without that the account stays on the credential its administrator still holds, which is the entire thing the gate exists to end. Falsified twice: dropping the same-password guard fails "refuses to reuse the password the admin handed over", and resolving on a rejected response fails "stays open on a rejected change, so the gate cannot be escaped" -- each breaking only its own test. 175 backend tests, 326 GUI/SDK tests, typecheck clean.
dc5d963 to
a983969
Compare
Coverage Report for puter.js SDK
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||
β¦ss is given Follows the previous commit. Dropping the email field entirely went one step too far: without an address the temporary password shown once in the panel is the only copy, and an admin who closes that panel has to issue a new one. The field is back, marked optional, and now it buys something concrete. `team_account_created` carried no credential -- it said the team "will send you a temporary password separately". It now carries the username and the temporary password, so an admin who supplies an address hands nothing over by side channel. `#notifyUser` takes extra template variables, and both credential-issuing paths pass the one they just minted: provisioning and re-issue. Re-issue passing the fresh credential rather than the stale one is the case worth checking, and there is a test that asserts the old password is absent from that mail. With no address nothing is sent, which was already true -- `#notifyUser` returns early without one -- and is now covered. The docs said the notice carries no credential in two places. Both corrected. Falsified: dropping the credential from the re-issue call fails "emails the fresh credential on re-issue, not the old one" and nothing else. 178 backend tests, 326 GUI/SDK tests, typecheck clean.
A provisioned account had no way to know it was one. That matters: the team can
reset its password and close it, which is exactly what the account-created email
already warns about, and nothing in the product repeated it afterwards.
`whoami` now carries `team: { uid, name }`. Two gates on it. Only user actors --
a seat's employer is no more an app's business than its phone number, which the
same handler already withholds. And only where `teams_enabled` is on, so a
deployment without teams is byte-identical.
It rides whoami rather than a route of its own because the sidebar needs it at
first paint. A `/teams/whoami` would add a request to every page load for every
user, and almost none of them are seats. The lookup costs nothing either way:
`getOrgSeat` is already cached, negative results included, precisely because
almost nothing is a seat. `team_name` comes off a join the query already made.
In the sidebar it sits under the Puter wordmark -- the conventional slot for
workspace context -- as a muted second line, hidden when the sidebar collapses.
Owners see nothing: they already know, and one may own several teams, so there
would be no single name to show.
The markup is a helper rather than another branch inside UIDashboard, matching
how appGroups/credits/usageBudget were pulled out, so it can be tested without
mocking the window stack.
Falsified: dropping the `isUser` gate fails "withholds it from an app actor" and
nothing else.
181 backend tests, 331 GUI/SDK tests, typecheck clean.
5bf6647 to
106978e
Compare
β¦allowance Three related limits. Seats per team now depend on the owner's plan: 4 free, 40 paid, decided by `subscriptionSatisfies(id, true)` -- which is `!FREE_SUBSCRIPTION_IDS.has(id)`, so a plan an extension adds counts as paid without core knowing its name. The existing `max_seats_per_team` still overrides both, so a deployment that already set it keeps what it asked for, and `max_seats_per_team_free` / `_paid` tune each. An unreadable plan takes the smaller cap: over-provisioning a free team is the worse failure. A seat of a team that pays for nothing resolves `org_seat_free`, half the registered free plan. Without it a team is a way to mint free tiers -- provision four seats and each arrives with a full free allowance nobody paid for. The figures are derived from `REGISTERED_USER_FREE` rather than restated, so the two cannot drift, and the id joins `FREE_SUBSCRIPTION_IDS` because nobody paid for it either and it must not satisfy a plan gate. It is a *default* resolver, so a paid team tier -- which only prod knows about, through `registerSubscriptionResolver` -- still outranks it. The lookup is `getOrgSeat`, already cached with its negatives, because almost nothing is a seat. Found while testing: the suite was reading `max_seats_per_team` out of the developer's own config.json, so the cap under test was whatever that file said. It would have passed here and failed in CI, which has no such file. The suite now pins the value and the cap tests set their own. Falsified three ways, each breaking only its own test: equal caps fails "lets a paid owner past four"; a resolver returning null, and an unhalved allowance, both fail "resolves half the free plan". 186 team/whoami tests, 159 metering tests, typecheck clean.
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.
β¦-in-URL `initgui` runs the verification gates in two places: the token-in-URL branch and the session-restore/login branch. The password gate went into the first only, so it never fired for the case it exists for -- a seat signing in through the login form with the credential its administrator issued. The account reached the desktop and then failed at every gated call with no prompt, which is the state the gate was written to prevent. Caught by manual testing, not by any test: both chains looked right in isolation. Added an invariant test over initgui's source asserting each gate appears on both paths and each loops until cleared, which is the shape of the mistake rather than the instance of it. Falsified: removing the login-path gate fails "runs the forced password change on both paths" with `expected 1 to be 2`.
It filtered on `org_owned`, but the GUI annotates members to `orgOwned` -- so the count was always zero and the card read "billed for 0 accounts" beside an accounts table saying two. Uses `membersBillingSummary` now, the same count the table shows, which also excludes suspended seats: they stop costing a per-account charge, which the naive filter would have billed for.
Per-seat billing context without per-seat subscriptions. PUT-1788 D1 stands: one tier per team, one Stripe subscription, quantity = seat count. The tier is still changed once, in the plan card. Four states, because "on Team Basic" is not true of every row. The owner is the payer and keeps their own personal plan, so they show as such rather than inheriting the team's. A suspended seat reads "not billed" -- it stops costing a per-account charge, which is the same rule the billing summary and the plan card's seat count already use. A seat of a team that bought nothing reads free, which is the reduced org-seat allowance. Everyone else shows the tier. The rule is a helper in teamsConsole so it is testable, like the rest of that file. Falsified: dropping the payer branch fails "says the owner is the payer, not a seat"; dropping the suspended branch fails "says a suspended seat is not billed".
Reverses PUT-1788 D1 at the UI. A team keeps one subscription; each account sits on its own tier within it. The plan card stops being the chooser and becomes a summary: what each tier costs per account, and how many accounts are on it. Choosing happens on the account, with a Change plan action per row, because that is the thing the tier now belongs to. `memberPlanLabel` reads the seat's own assignment rather than the team's single tier, so two accounts can honestly show different plans. The other three states are unchanged and still matter: the owner is the payer, a suspended seat is not billed whatever it was on, and a seat nobody bought a tier for is free. The action only appears where a billing extension is present and a catalogue came back, so a deployment that sells nothing shows prices and no dead buttons.
A row now carries up to five actions -- change plan, reissue, suspend or enable, delete -- and five labelled buttons do not fit the cell. Each becomes a 30px icon button. The label is not dropped, only hidden: it stays as `title`, as `aria-label`, and as visually-hidden text, so a screen reader and a hover both still get it. The glyphs are `aria-hidden`, since the button already carries the name. Inline SVG rather than files under `icons/`: these are one-place 24px line glyphs, and the dashboard already inlines its sidebar chevron the same way. An `edit` glyph is defined but unused, ready for the account-edit action. Falsified: removing the visually-hidden label fails "keeps the label reachable without showing it".
Per-tier quantities need to know *which* seats are active, not just how many: the count has to be split by the tier each one is on. `countActiveSeats` answers the old question and stays for the cap and the console copy. Keyed by `uid` rather than the numeric id, because the billing side holds uids and would otherwise have to look the team up twice. Same suspension filter, and a test asserts the two agree so they cannot drift apart. Falsified: dropping the suspension clause fails "leaves out a suspended seat, matching countActiveSeats".
It described a team-wide tier, which stopped existing when plans moved to the account. What it still showed -- what each tier costs -- is in the Change plan picker, next to the account it applies to, so nothing is lost by removing it. `teamPlan.js` and its tests go with it, along with twelve i18n keys nothing reads any more. `state.plan` stays: the Plan column and the picker both need the catalogue and the seat assignments.
The Teams tab was asking which tier with a UIAlert, which looked nothing like the personal plan modal. It now dispatches the seat and lets the extension render the grid, so the two match and OSS stays free of prices. Carries `username` and `currentTier` so the picker can title itself and mark the plan the account is already 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.
A provisioned account could rename itself, delete itself, and see a
Billing tab for a subscription it does not hold β all of it the team's,
not the account's. Each is now refused server-side and dropped from the
UI, keyed on one predicate: whoami reports a team only for an org-owned
seat, and the owner joined their own team.
The Teams tab showed a seat nothing but its own audit rows, so a member
could not see who else was on the team they were told they shared it
with. It now lists them; listMembers was already membership-gated and
already withholds from a member what is not theirs.
The dashboard share modal had no way to reach a team, though the desktop
dialog has had one since team sharing shipped and the SDK has always
taken `{ team }`. Same control, same copy, same helper. The access list
needed a team bucket to go with it: a team share names no holder, so the
aggregate dropped it and a team you had just shared with vanished.
`config.default.json` shipped `max_seats_per_team: 50`, and that flat
override wins over the plan, so the free/paid caps were dead code on
every deployment that did not delete the key. A free owner got 50 seats.
The defaults now carry the two per-plan numbers and leave the flat
override unset; a deployment that wants one number for everyone still
sets it and still wins.
`#ownerPays` asked metering about an `{id, uuid}` stub, so a resolver
keyed on any other field missed and the answer came down to whether
something else had cached that user's plan first β the same team, at the
same seat count, was refused or allowed depending on nothing.
Running out of seats now says what raises the limit and offers the plan
picker, rather than reporting the number and stopping there. The record
table pages at ten rows instead of running off the bottom, and the team
card no longer labels the absence of a handle nothing here can set.
`i18n()` echoes a key it has no translation for, and a team tier has none β so a seat's dashboard read "team-basic" where a personal plan reads "Basic". The offering already travels with the subscription, so its display name is right there; `i18n` stays the fallback for the personal tiers that do have keys.
The Usage card's Upgrade link relabels to "Manage β" for anyone on a plan, including a seat whose plan is its team's. Following it only reaches a dialog saying so. Hidden for a seat, unchanged for everyone else.
Salazareo
left a comment
There was a problem hiding this comment.
mightve missed it, but think the free org seat isnt being included in the gate for subscription requirement
Review catch by @Salazareo: `org_seat_free` reached `FREE_SUBSCRIPTION_IDS` and so the `requireSubscription` gate, but two other surfaces decide on plan and neither consults that set. `bySubscription` maps name `user_free` and `temp_free`. A plan that matches no key fell through to the top-level `limit` -- the paid cap -- so a seat outranked an ordinary free account: 240 event listings a minute against their 120, and the same shape across the kv, notification, subdomain and worker drivers. Both resolvers now fall back to the `user_free` entry for anything in the free set, which covers every driver at once and any free plan added later. `subscriberOnly` compared against the two named ids, so a seat could reach a paid-only model. It asks the set now. A paid plan that names no cap of its own still takes the base, and an unresolved plan still takes the base; there are tests for both so the fallback cannot widen into "free by default".
|
Good catch β you were right, not only one gate.
1. Every
Same shape in the kv, notification, subdomain and worker drivers. 2. Fixed at the two resolvers rather than by adding Guarded against over-correcting: a paid plan that names no cap of its own still takes the base, and an unresolved plan still takes the base. Tests for both, plus a seat held to the free cap and a seat refused a subscriber-only model.
|
Everything a team-provisioned account meets on its way in, and everything its
owner meets running the team. Found by testing Teams on a region and then
driving the whole flow locally against Stripe; seventeen commits, each
independently reviewable.
b19b312a983969fa36f2efb74789106978ee886e8663f857cefe6ef02083ef4f2c9737187e4c8b8b0650c2a975b06bdc261efe086bafe8af480d6bc63f857cde74b0c8d90418Closes PUT-1792. Most of this is beyond that ticket β see Scope at the end.
1. Email was mandatory, and confirmation was demanded
createMemberread the address with#requireString, andprovisionAccountvalidated and de-duplicated it. An admin creating ten seats had to invent ten addresses and then live with ten uniqueness constraints β for accounts that sign in by username.It also set
requires_email_confirmation: true, reasoning that an admin-supplied address is unverified. True, butrequireVerifiedAccountturns away on exactlyso a fresh seat was asked to confirm an address it may not hold, and could not use the product until it did. The team creating the account is the trust anchor, not the mailbox. Now
falseeither way.idx_user_owned_emailis partial and skips password-null rows, so omitting the address sidesteps the index rather than creating a collision surface β two emailless seats do not conflict, and there is a test for it.2. Nothing asked the seat to change its password
The backend already refused every route with
password_change_required, and/user-protected/change-passwordwas already exempt β "the one route an account owing a password change may reach". The client half was missing entirely: zero references to that code in the GUI, so a seat signed in and then failed at everything with no prompt.Two gaps. The flag never reached the client β neither the login response nor
/whoamicarriedrequires_password_change. And no window could serve as a gate:UIWindowChangePasswordis a settings dialog, closable and never resolving on success.UIWindowPasswordChangeRequiredmirrors the existing*Requiredwindows, resolves only once the change lands, andinitguiloops on it. It runs last, matchingassertVerifiedAccount's own order. It refuses a new password equal to the current one β otherwise the account stays on the credential its administrator still holds, which is the entire thing the gate exists to end.initguiruns its verification gates in two places β the token-in-URL branch and the session-restore/login branch β and the first commit wired only one, so signing in with a password skipped the gate entirely.fa36f2eadds it to both and pins the invariant with a test that asserts each gate appears twice.3. The credential had no delivery
team_account_createdsaid the team "will send you a temporary password separately" and carried none. It now carries the username and temporary password, so an admin who supplies an address hands nothing over by side channel. Both credential-issuing paths pass the one they just minted β provisioning and re-issue; there is a test asserting the old password is absent from the re-issue mail.With no address nothing is sent, which was already true (
#notifyUserreturns early) and is now covered.4. A seat could not tell it was one
whoaminow carriesteam: { uid, name }, gated on user actors β a seat's employer is no more an app's business than its phone number, which the same handler already withholds β and onteams_enabled, so a deployment without teams is byte-identical.It rides whoami rather than a route of its own because the sidebar needs it at first paint; a
/teams/whoamiwould add a request to every page load for every user, and almost none are seats. The lookup costs nothing:getOrgSeatis already cached, negatives included, andteam_namecomes off a join the query already made.In the sidebar it sits under the Puter wordmark as a muted second line, hidden when collapsed. Owners see nothing β they already know, and one may own several teams, so there is no single name to show.
5. Team size followed nothing at all
4 seats free, 40 paid, decided by
subscriptionSatisfies(id, true)β which is!FREE_SUBSCRIPTION_IDS.has(id), so a plan an extension registers counts as paid without core knowing its name. An unreadable plan takes the smaller cap: over-provisioning a free team is the worse failure.Two defects kept that from being true of any real deployment, both found by testing it rather than reading it:
config.default.jsonshippedmax_seats_per_team: 50, and the flat override wins over the plan β so the whole free/paid branch was dead code unless an operator deleted a key nothing told them about. A free owner got 50 seats. The defaults now carrymax_seats_per_team_free/_paidand leave the flat override unset; a deployment that wants one number for everyone still sets it and still wins. A test readsconfig.default.jsonand fails if the flat key comes back.#ownerPaysasked metering about an{id, uuid}stub. A resolver keyed on any other field missed, so the answer came down to whether something else had cached that user's plan first β the same team, at the same seat count, was refused or allowed depending on nothing. It passes the whole user row now, and a test asserts the actor carries the username.A seat of a team that pays for nothing gets half the free plan (
org_seat_free). Without it a team is a way to mint free tiers β provision four seats and each arrives with a full free allowance nobody paid for. The figures are derived fromREGISTERED_USER_FREErather than restated, so the two cannot drift, and the id joinsFREE_SUBSCRIPTION_IDSbecause nobody paid for it either and it must not satisfy a plan gate. It is a default resolver, so a paid team tier β which only prod knows about, viaregisterSubscriptionResolverβ still outranks it.Running out of seats used to report the number and stop. It now says what raises the limit and offers the plan picker, and the button appears only where a billing extension put one on the page.
6. A plan belongs to an account, not to a team
The first pass sold one tier for the whole team. That is the wrong unit: a team buys Puter for particular people, and the people differ. Each seat now carries its own tier β one subscription, one Stripe item per tier, quantity = seats on that tier β and the accounts table has a Plan column with a per-row action.
The plan card came out again (
c2a975b) once every row carried its own, and the picker moved to the billing extension (06bdc26) so it reuses the personal plan modal's own frame rather than a lookalike. OSS renders from a prod-served catalogue β the precedentTabHome.jsalready set β and carries no pricing.1efe086is the one that made it work. The action did nothing, through three layers:listMembersnever returned a member'suuid, the SDK'stoMemberdropped it, and the Plan column readseatTiers[undefined]so every seat rendered as Free. The uuid is owner-only β it is what billing keys a seat's plan on, and one member has no business identifying another.7. A seat's account surface was not its own
A provisioned account could rename itself, delete itself, and see a Billing tab for a subscription it does not hold. Each is now refused server-side and dropped from the UI, keyed on one predicate (
isOrgSeat): whoami reports a team only for an org-owned seat, and the owner joined their own team.The Teams tab showed a seat nothing but its own audit rows, so a member could not see who else was on the team they were told they shared with. It lists them now;
listMemberswas already membership-gated and already withholds from a member what is not theirs.The dashboard share modal had no way to reach a team, though the desktop dialog has had one since team sharing shipped and the SDK has always taken
{ team }. Same control, same copy, sameshareTeamshelper. The access list needed a team bucket to go with it: a team share names no holder, so the aggregate dropped it and a team you had just shared with vanished from the list.Smaller things from the same pass: the record table pages at ten rows instead of running off the bottom, and the team card no longer labels the absence of a handle that nothing here can set.
8. A team tier printed as its policy id
i18n()echoes a key it has no translation for. Personal tiers have keys;team-basicdoes not β so a seat's dashboard read "team-basic" where apersonal plan reads "Basic". The offering already travels with the subscription
(prod's
subscriptions/currentnow carries a seat's team tier), so the displayname was already on the wire.
i18nstays the fallback for the tiers that dohave keys.
Found by driving a real team purchase against a local instance, not by reading.
A seat's dashboard, after the fix (
~/Desktop/team-billing-fixes-20260911/07-seat-sees-its-plan-not-free.png)9. A seat was offered a plan link it cannot use
The Usage card's Upgrade link relabels to "Manage β" for anyone on a plan β
including a seat, whose plan belongs to its team. Following it only reaches a
dialog saying so. Hidden for a seat on the same
isOrgSeatpredicate the rest ofthe seat restrictions use; unchanged for everyone else, verified both ways
against a live instance (owner still sees "Upgrade β", seat sees nothing).
Test bugs this surfaced
max_seats_per_teamout of the developer's ownconfig.json, so the cap under test was whatever that file said. It would have passed locally and failed in CI, which has no such file. The suite pins the value and the cap tests set their own.429. The fixture is built through the service instead of over the wire.Testing
Driven end to end against a local instance with Stripe in test mode β real checkout sessions, real webhooks, real seats:
Falsified, each breaking only its own test:
Scope
Only commit 1 is PUT-1792. The rest came out of the same rounds of testing and are small enough to have travelled with it, but say the word and I will split any of them out.
Two calls I made rather than guessed, both cheap to reverse:
org_owned = 1accounts get the halved policy. That also preserves the disjointness the two subscription resolvers rely on.Two things worth a reviewer's eye:
prodMeteringAndBillingalso registers a default resolver, for corporate email domains. Two defaults meansfirstResolverpicks by registration order, so a seat on a trusted domain could land oncorporather thanorg_seat_free. Same class of problem the team resolver already avoids by keying ongetOrgSeat, and worth a follow-up if that overlap is real in prod.