feat: tell a seat which team its account belongs to

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.
This commit is contained in:
Juan Castro
2026-09-10 16:52:10 -04:00
parent fb74789de1
commit 106978e714
8 changed files with 191 additions and 3 deletions
+76
View File
@@ -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();
+17
View File
@@ -253,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) {
+3 -1
View File
@@ -53,6 +53,7 @@ export interface OrgSeatRow {
uuid: string;
username: string;
team_uid: string;
team_name: string | null;
owner_user_id: number;
}
@@ -626,7 +627,8 @@ export class TeamStore extends PuterStore {
async #readOrgSeat(userId: number): Promise<OrgSeatRow | null> {
const rows = (await this.clients.db.read(
'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' +
'g.`uid` AS `team_uid`, g.`owner_user_id` ' +
'g.`uid` AS `team_uid`, g.`name` AS `team_name`, ' +
'g.`owner_user_id` ' +
'FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
+5 -1
View File
@@ -53,6 +53,7 @@ import TabUsage from './TabUsage.js';
import TabAccount from './TabAccount.js';
import TabSecurity from './TabSecurity.js';
import TabTeams from './TabTeams.js';
import teamBadgeHtml from './teamBadge.js';
// Registry of built-in tabs
const builtinTabs = [
@@ -119,7 +120,10 @@ async function UIDashboard (options) {
h += '<div class="dashboard-sidebar hide-scrollbar">';
// Sidebar header with logo and collapse toggle
h += '<div class="dashboard-sidebar-header">';
h += `<div class="dashboard-sidebar-logo"><img class="dashboard-sidebar-logo-light" src="${window.icons['logo.svg']}" alt="Puter"><img class="dashboard-sidebar-logo-dark" src="${window.icons['logo-white.svg']}" alt="Puter"><span>Puter</span></div>`;
h += '<div class="dashboard-sidebar-brand">';
h += `<div class="dashboard-sidebar-logo"><img class="dashboard-sidebar-logo-light" src="${window.icons['logo.svg']}" alt="Puter"><img class="dashboard-sidebar-logo-dark" src="${window.icons['logo-white.svg']}" alt="Puter"><span>Puter</span></div>`;
h += teamBadgeHtml(window.user);
h += '</div>';
h += '<button class="dashboard-sidebar-collapse-toggle">';
h += `<img class="sidebar-toggle-close" src="${window.icons['sidebar-close.svg']}">`;
h += `<img class="sidebar-toggle-open" src="${window.icons['sidebar-open.svg']}">`;
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The team name under the sidebar wordmark. Empty for anyone but a seat
* `whoami` sets `team` only for an account a team pays for.
*
* @param {object} [user] `window.user`
* @returns {string} markup, or '' when there is nothing to say
*/
export const teamBadgeHtml = (user) => {
const name = user?.team?.name;
if ( typeof name !== 'string' || name.trim() === '' ) return '';
const label = window.html_encode(name);
const title = window.html_encode(i18n('teams_account_of', [name]));
return `<div class="dashboard-sidebar-team" title="${title}">${label}</div>`;
};
export default teamBadgeHtml;
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
globalThis.i18n = (key, args) => `${key}:${(args ?? []).join(',')}`;
globalThis.window = { html_encode: (v) => String(v).replace(/</g, '&lt;') };
const { teamBadgeHtml } = await import('./teamBadge.js');
describe('the sidebar team badge', () => {
it('names the team for a seat', () => {
const h = teamBadgeHtml({ team: { uid: 't-1', name: 'Acme Corp' } });
expect(h).toContain('dashboard-sidebar-team');
expect(h).toContain('Acme Corp');
});
it('says nothing for anyone who is not a seat', () => {
// whoami omits `team` entirely for an ordinary account or an owner.
for (const user of [undefined, {}, { team: undefined }, { team: {} }]) {
expect(teamBadgeHtml(user)).toBe('');
}
});
it('treats a blank name as nothing to say', () => {
expect(teamBadgeHtml({ team: { uid: 't-1', name: ' ' } })).toBe('');
});
it('encodes the name, which an admin chose', () => {
const h = teamBadgeHtml({ team: { name: '<script>x</script>' } });
expect(h).not.toContain('<script>');
expect(h).toContain('&lt;script');
});
it('explains itself in the title, for a truncated name', () => {
const h = teamBadgeHtml({ team: { name: 'Acme' } });
expect(h).toContain('teams_account_of:Acme');
});
});
+18 -1
View File
@@ -268,6 +268,22 @@ body {
margin-bottom: 10px;
}
.dashboard-sidebar-brand {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.dashboard-sidebar-team {
font-size: 12px;
color: var(--dashboard-text-muted);
padding-left: 34px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-sidebar-logo {
display: flex;
gap: 10px;
@@ -344,7 +360,8 @@ body {
padding: 4px 0 12px;
}
.dashboard-sidebar.collapsed .dashboard-sidebar-logo {
.dashboard-sidebar.collapsed .dashboard-sidebar-logo,
.dashboard-sidebar.collapsed .dashboard-sidebar-team {
display: none;
}
+1
View File
@@ -528,6 +528,7 @@ const en = {
teams_create_team_prompt: 'What should the team be called?',
teams_accounts: 'Accounts',
teams_no_accounts: 'This team has no accounts yet.',
teams_account_of: 'This account belongs to %%',
teams_add_account: 'Add an account',
teams_email_optional: 'Email (optional)',
teams_add_account_email_hint: 'If you add an address, we email the username and temporary password to it. Otherwise the password below is the only copy.',