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.
This commit is contained in:
Juan Castro
2026-09-15 14:48:11 -04:00
parent ad3d15e7e2
commit f428797a79
3 changed files with 68 additions and 6 deletions
+3 -2
View File
@@ -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 ───────────────────────────────────────────────────
@@ -29,12 +29,13 @@ type EmitAndWait = (key: string, event: unknown, meta: unknown) => unknown;
const makeService = (
config: Record<string, unknown> = {},
emitAndWait: EmitAndWait = async () => undefined,
services: Record<string, unknown> = {},
) => {
const args = [
{ env: 'prod', domain: 'puter.test', ...config },
{ event: { emitAndWait: vi.fn(emitAndWait) } },
{},
{},
services,
] as unknown as ConstructorParameters<typeof PuterHomepageService>;
return new PuterHomepageService(...args);
};
@@ -54,6 +55,7 @@ const render = async (
req: Request = makeReq(),
meta: Record<string, unknown> = { title: 'Puter' },
launchOptions: Record<string, unknown> = {},
actor: unknown = null,
): Promise<string> => {
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,
@@ -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 {
</html>`;
}
/**
* 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<boolean> {
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