Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b19b312
feat: a team seat needs no email address, and is never asked to confi…
jfcastro92 Sep 10, 2026
a983969
feat: prompt a team seat to choose its own password on first sign-in
jfcastro92 Sep 10, 2026
fb74789
feat: email a seat its username and temporary password, when an addre…
jfcastro92 Sep 10, 2026
106978e
feat: tell a seat which team its account belongs to
jfcastro92 Sep 10, 2026
e886e86
feat: size a team by whether its owner pays, and halve a free seat's …
jfcastro92 Sep 10, 2026
efe6ef0
feat: show a team's plan in the Teams tab, and let the owner change it
jfcastro92 Sep 10, 2026
fa36f2e
fix: run the forced password change on the login path, not only token…
jfcastro92 Sep 10, 2026
2083ef4
fix: the plan card counted no seats
jfcastro92 Sep 10, 2026
f2c9737
feat: show each account's plan in the team's accounts table
jfcastro92 Sep 11, 2026
187e4c8
feat: set a plan per account, not one tier for the whole team
jfcastro92 Sep 11, 2026
bafe8af
feat: icon buttons for the per-account actions
jfcastro92 Sep 11, 2026
b8b0650
feat: list the uuids of the seats a team is billed for
jfcastro92 Sep 11, 2026
c2a975b
refactor: drop the team plan card
jfcastro92 Sep 11, 2026
06bdc26
refactor: let the billing extension own the plan picker
jfcastro92 Sep 11, 2026
1efe086
fix: give the owner the seat uuid the plan action is keyed on
jfcastro92 Sep 11, 2026
480d6bc
feat: give a team seat the account surface that is actually its own
jfcastro92 Sep 11, 2026
63f857c
fix: make the seat cap the owner's plan decides actually apply
jfcastro92 Sep 11, 2026
de74b0c
fix: name the plan a team bought, not its policy id
jfcastro92 Sep 11, 2026
8d90418
fix: don't offer a seat a plan link it cannot use
jfcastro92 Sep 11, 2026
15527ea
fix: hold a team seat to the free caps it was meant to have
jfcastro92 Sep 14, 2026
c3e2717
fix: close the review findings on the seat experience
jfcastro92 Sep 14, 2026
018196f
feat: i18n keys for the team billing surfaces
jfcastro92 Sep 14, 2026
3bf5ba3
feat: i18n keys for the seat cancel flow
jfcastro92 Sep 14, 2026
27dd0f8
docs: publish only the team surface an app can actually call
jfcastro92 Sep 15, 2026
230241d
fix: let an owner retire the seats of a deleted team, and only those
jfcastro92 Sep 15, 2026
ad3d15e
feat: stage the teams rollout behind an email-domain allowlist
jfcastro92 Sep 15, 2026
f428797
feat: render the teams UI for allowlisted users without the global sw…
jfcastro92 Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 20 additions & 6 deletions config.template.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions extensions/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ beforeAll(async () => {
create_shortcut: true,
payment_bypass: true,
},
teams_enabled: true,
} as never);
});

Expand All @@ -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();

Expand Down
22 changes: 20 additions & 2 deletions extensions/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -292,8 +311,7 @@ export const handleWhoami = async (
}

const subscription = details.subscription as
| { offering?: Record<string, unknown> }
| undefined;
{ offering?: Record<string, unknown> } | undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
Expand Down
8 changes: 5 additions & 3 deletions src/backend/clients/email/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,11 @@ support@puter.com immediately.
subject: 'Your {{team_name}} account on Puter',
html: `
<p>Hi there,</p>
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
They will send you a temporary password separately; you will be asked to
choose your own the first time you sign in.</p>
<p>{{team_name}} has created a Puter account for you.</p>
<p>Username: <b>{{username}}</b><br>
Temporary password: <b>{{temporary_password}}</b></p>
<p>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.</p>
<p>What this means:</p>
<ul>
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
Expand Down
94 changes: 94 additions & 0 deletions src/backend/controllers/auth/AuthController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5006,6 +5006,32 @@ describe('AuthController password recovery', () => {
expect(after!.pass_recovery_token).toBeTruthy();
});

it('send-pass-recovery-email: refuses a team seat, and writes no token', async () => {
// The seat's address is admin-supplied and unverified; recovery there
// would be a takeover channel. Its recovery is the admin's reset.
const { user: owner } = await makeUserAndActor();
const { user: seat } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});

const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ username: seat.username }),
res,
);
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
const after = await server.stores.user.getById(seat.id, { force: true });
expect(after!.pass_recovery_token).toBeFalsy();
});

it('verify-pass-recovery-token: 400 on missing token', async () => {
await expect(
controller.handleVerifyPassRecoveryToken(makeReq({}), makeRes()),
Expand Down Expand Up @@ -5253,6 +5279,52 @@ describe('AuthController user-protected mutations (validation paths)', () => {
).rejects.toMatchObject({ statusCode: 400 });
});

it('change-email: 403 for an account its team provisioned', async () => {
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});

await expect(
controller.handleChangeEmail(
makeReq({ new_email: `moved_${uniq()}@example.com` }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
});

it('change-username: 403 for an account its team provisioned', async () => {
// The console lists members by username and the audit log records them
// by it; a self-service rename would desync both.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
// addMember refuses to adopt an account that has one.
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});

await expect(
controller.handleChangeUsername(
makeReq({ new_username: `r_${uniq()}` }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });

const after = await server.stores.user.getById(seat.id, { force: true });
expect(after!.username).toBe(seat.username);
});

it('change-username: persists the rename and emits user.username-changed', async () => {
const { user, actor } = await makeUserAndActor();
const newUsername = `r_${uniq()}`;
Expand Down Expand Up @@ -6487,6 +6559,28 @@ describe('AuthController.handleDeleteOwnUser', () => {
expect(after).toBeFalsy();
});

it('refuses, and keeps the row, for an account its team provisioned', async () => {
// The team is billed for the seat and closing it is theirs to do, from
// the console that keeps the audit trail.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});

await expect(
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 403 });

const after = await server.stores.user.getById(seat.id, { force: true });
expect(after).toBeTruthy();
});

it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
// the sqlite ones the test harness runs — add it so the delete path
Expand Down
Loading
Loading