diff --git a/config.default.json b/config.default.json index bceb16cff2..949f765154 100644 --- a/config.default.json +++ b/config.default.json @@ -24,7 +24,8 @@ "disable_user_signup": false, "teams_enabled": false, "max_teams_per_user": 1, - "max_seats_per_team": 50, + "max_seats_per_team_free": 4, + "max_seats_per_team_paid": 40, "strict_email_verification_required": false, "gui_assets_root": "./src/gui", "puterjs_root": "./src/puter-js/dist", diff --git a/config.template.jsonc b/config.template.jsonc index 5bfb0ac0a6..54f65d8770 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -285,12 +285,26 @@ // deleting yours frees the slot. Default 1. "max_teams_per_user": 1, // - // Seats one team may provision. Each seat is a real Puter account - // taking a name from the global username pool, so this is what bounds a - // team's blast radius until per-seat billing lands. Lowering it below - // a team's current seat count blocks new provisioning and disables - // nobody. Default 50. - "max_seats_per_team": 50, + // Seats one team may provision. Each seat is a real Puter account taking a + // name from the global username pool, so this is what bounds a team's blast + // radius. The owner's plan decides which of the two applies; defaults are 4 + // and 40. Lowering either below a team's current seat count blocks new + // provisioning and disables nobody. Deployments upgrading from the old flat + // default of 50: without a billing extension every owner reads as free, so + // set `max_seats_per_team` to keep the old behavior. + "max_seats_per_team_free": 4, + "max_seats_per_team_paid": 40, + // + // One flat cap for every team, whatever the owner pays. Setting it + // overrides both of the above, so a deployment that does not sell seats can + // ignore plans entirely. Unset by default — leave it that way to keep the + // free cap meaningful. + // "max_seats_per_team": 50, + // + // Staged rollout: only these email domains may create a team, and the tab + // renders for them even with `gui_params.teams_ui` off. Members of an + // existing team always pass. Unset means everyone, gated by `teams_ui`. + // "teams_allowed_email_domains": ["puter.com"], // ── Notifications ─────────────────────────────────────────────────── // How long a notification is kept, in days from creation. Acknowledged or diff --git a/extensions/whoami.test.ts b/extensions/whoami.test.ts index 8b54febfdd..984b7697fc 100644 --- a/extensions/whoami.test.ts +++ b/extensions/whoami.test.ts @@ -48,6 +48,7 @@ beforeAll(async () => { create_shortcut: true, payment_bypass: true, }, + teams_enabled: true, } as never); }); @@ -66,6 +67,81 @@ const seedUser = async () => { }; describe('whoami extension — handleWhoami', () => { + // The sidebar label needs this at boot, which is why it rides whoami + // rather than a call of its own. + describe('the team an account belongs to', () => { + // A seat is created, never adopted, so it must have no password. + const seedSeat = async () => { + const slug = Math.random().toString(36).slice(2, 8); + return server.stores.user.create({ + username: `wseat_${slug}`, + uuid: uuidv4(), + password: null, + email: null, + }); + }; + + const seatOf = async (teamName: string) => { + const owner = await seedUser(); + const seat = await seedSeat(); + const team = await server.stores.team.create({ + ownerUserId: owner.id as number, + name: teamName, + handle: `wt-${Math.random().toString(36).slice(2, 9)}`, + }); + await server.stores.team.addMember(team.uid, seat.id as number, { + orgOwned: true, + }); + return { seat, team }; + }; + + it('names the team for a seat', async () => { + const { seat, team } = await seatOf('Acme Corp'); + const { res, captured } = makeRes(); + + await runWithContext( + { actor: { user: { uuid: seat.uuid, id: seat.id as number } } }, + () => handleWhoami(makeReq(), res), + ); + + expect((captured.body as { team?: unknown }).team).toEqual({ + uid: team.uid, + name: 'Acme Corp', + }); + }); + + it('says nothing for an account that is not a seat', async () => { + const user = await seedUser(); + const { res, captured } = makeRes(); + + await runWithContext( + { actor: { user: { uuid: user.uuid, id: user.id as number } } }, + () => handleWhoami(makeReq(), res), + ); + + expect(captured.body).not.toHaveProperty('team'); + }); + + it('withholds it from an app actor', async () => { + // Same class as the phone number: a seat's employer is not an + // app's business. + const { seat } = await seatOf('Acme Corp'); + const { res, captured } = makeRes(); + + await runWithContext( + { + actor: { + user: { uuid: seat.uuid, id: seat.id as number }, + app: { uid: 'app-1' }, + }, + }, + () => handleWhoami(makeReq(), res), + ); + + expect(captured.body).not.toHaveProperty('team'); + }); + }); + it('returns 401 when no actor is on the context', async () => { const { res, captured } = makeRes(); diff --git a/extensions/whoami.ts b/extensions/whoami.ts index a2e10dcc42..57ba963e9a 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -156,6 +156,8 @@ export const handleWhoami = async ( // every app actor. Only the verification flag ships. requires_phone_verification: user.requires_phone_verification, requires_card_verification: user.requires_card_verification, + // A seat reaches nothing until it replaces its admin's password. + requires_password_change: user.requires_password_change, // The SMS-to-card escape hatch: true once this user is out of SMS send // attempts and may verify a card instead. It has to ship from here // because /send-confirm-phone can no longer say so — by the time the @@ -251,6 +253,23 @@ export const handleWhoami = async ( details.directories = directories; } + // The team an account belongs to, when it is one a team pays for. User + // actors only, and only where teams are on. + if (isUser && extension.config.teams_enabled === true) { + try { + const seat = await stores.team.getOrgSeat(user.id); + if (seat) { + details.team = { + uid: seat.team_uid, + name: seat.team_name ?? null, + }; + } + } catch (e) { + // Never fail whoami over this; the account still works without it. + console.warn('[whoami] team lookup failed:', (e as Error).message); + } + } + // Last activity const lastActivityTs = toUnixSeconds(user.last_activity_ts); if (lastActivityTs !== undefined) { @@ -292,8 +311,7 @@ export const handleWhoami = async ( } const subscription = details.subscription as - | { offering?: Record } - | undefined; + { offering?: Record } | undefined; if (subscription?.offering) { delete subscription.offering.group; delete subscription.offering.benefits; diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index 0eaf367305..8fed1986c8 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -351,9 +351,11 @@ support@puter.com immediately. subject: 'Your {{team_name}} account on Puter', html: `

Hi there,

-

{{team_name}} has created a Puter account for you: {{username}}. -They will send you a temporary password separately; you will be asked to -choose your own the first time you sign in.

+

{{team_name}} has created a Puter account for you.

+

Username: {{username}}
+Temporary password: {{temporary_password}}

+

You will be asked to choose your own password the first time you sign in. +This temporary one stops working then, and it expires on its own if unused.

What this means: