From f428797a79b0a0922536e39c1d12c02397498cfb Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 15 Sep 2026 14:48:11 -0400 Subject: [PATCH] feat: render the teams UI for allowlisted users without the global switch `gui_params.teams_ui` stays the deployment-wide switch; with it off, the tab now also renders for a signed-in user who passes the email-domain allowlist (membership included, so seats see their roster). Anonymous renders hide it, and the API keeps deciding real access either way. --- config.template.jsonc | 5 ++- .../homepage/PuterHomepageService.test.ts | 44 ++++++++++++++++++- .../services/homepage/PuterHomepageService.ts | 25 ++++++++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/config.template.jsonc b/config.template.jsonc index 07f757206..54f65d877 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -301,8 +301,9 @@ // free cap meaningful. // "max_seats_per_team": 50, // - // Staged rollout: only these email domains may create a team or see the - // tab. Members of an existing team always pass. Unset means everyone. + // 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 ─────────────────────────────────────────────────── diff --git a/src/backend/services/homepage/PuterHomepageService.test.ts b/src/backend/services/homepage/PuterHomepageService.test.ts index ca7e57070..437cbeb84 100644 --- a/src/backend/services/homepage/PuterHomepageService.test.ts +++ b/src/backend/services/homepage/PuterHomepageService.test.ts @@ -29,12 +29,13 @@ type EmitAndWait = (key: string, event: unknown, meta: unknown) => unknown; const makeService = ( config: Record = {}, emitAndWait: EmitAndWait = async () => undefined, + services: Record = {}, ) => { const args = [ { env: 'prod', domain: 'puter.test', ...config }, { event: { emitAndWait: vi.fn(emitAndWait) } }, {}, - {}, + services, ] as unknown as ConstructorParameters; return new PuterHomepageService(...args); }; @@ -54,6 +55,7 @@ const render = async ( req: Request = makeReq(), meta: Record = { title: 'Puter' }, launchOptions: Record = {}, + actor: unknown = null, ): Promise => { let sent = ''; const res = { @@ -61,7 +63,11 @@ const render = async ( sent = html; }, } as unknown as Response; - await service.send({ req, res }, meta as never, launchOptions as never); + await service.send( + { req, res, actor } as never, + meta as never, + launchOptions as never, + ); return sent; }; @@ -233,6 +239,40 @@ describe('PuterHomepageService — gui() parameters', () => { ).toEqual({ login: true, signup: true }); }); + it('shows the teams UI to everyone with the deployment switch on', async () => { + const on = makeService({ gui_params: { teams_ui: true } }); + expect(guiParamsOf(await render(on)).teams_ui).toBe(true); + expect(guiParamsOf(await render(makeService())).teams_ui).toBe(false); + }); + + it('shows the teams UI per user on the domain allowlist, switch off', async () => { + const teamsAvailableTo = vi.fn(async () => true); + const service = makeService( + { teams_allowed_email_domains: ['puter.com'] }, + undefined, + { team: { teamsAvailableTo } }, + ); + + const staff = { user: { id: 7, email: 'j@puter.com' } }; + expect( + guiParamsOf(await render(service, makeReq(), undefined, {}, staff)) + .teams_ui, + ).toBe(true); + expect(teamsAvailableTo).toHaveBeenCalledWith(7, 'j@puter.com'); + + // Anonymous renders hide it; the API decides real access anyway. + expect(guiParamsOf(await render(service)).teams_ui).toBe(false); + + teamsAvailableTo.mockResolvedValueOnce(false); + expect( + guiParamsOf( + await render(service, makeReq(), undefined, {}, { + user: { id: 9, email: 'x@gmail.com' }, + }), + ).teams_ui, + ).toBe(false); + }); + it('advertises notification events only with the fold-in switched on', async () => { expect( guiParamsOf(await render(makeService())).eventsNotifications, diff --git a/src/backend/services/homepage/PuterHomepageService.ts b/src/backend/services/homepage/PuterHomepageService.ts index bdfe93abd..5c1a1c527 100644 --- a/src/backend/services/homepage/PuterHomepageService.ts +++ b/src/backend/services/homepage/PuterHomepageService.ts @@ -230,8 +230,8 @@ export class PuterHomepageService extends PuterService { this.config.disable_user_signup || this.config.gui_params?.disable_temp_users, ), - // Off until the teams UI ships; the API can be on without it. - teams_ui: this.config.gui_params?.teams_ui === true, + // Deployment-wide switch, or per user via the domain allowlist. + teams_ui: await this.#teamsUiFor(actor), domain: this.config.domain, env, api_base_url: this.config.api_base_url, @@ -433,6 +433,27 @@ export class PuterHomepageService extends PuterService { `; } + /** + * The deployment-wide switch shows the teams UI to everyone; otherwise a + * signed-in user on the domain allowlist (or already in a team) gets it. + * The API gates real access either way — this only decides the render. + */ + async #teamsUiFor(actor: Actor | null): Promise { + if (this.config.gui_params?.teams_ui === true) return true; + const domains = this.config.teams_allowed_email_domains; + if (!Array.isArray(domains) || domains.length === 0) return false; + const user = actor?.user; + if (typeof user?.id !== 'number') return false; + try { + return await this.services.team.teamsAvailableTo( + user.id, + user.email ?? null, + ); + } catch { + return false; + } + } + #originFromRequest(req: Request): string { // Prefer the pre-computed `config.origin` (protocol + domain + port). // Without it, non-80/443 deployments end up with URLs missing the