From 106978e714a5a7b74116d923b6c328e062c024dc Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 10 Sep 2026 16:50:49 -0400 Subject: [PATCH] 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. --- extensions/whoami.test.ts | 76 ++++++++++++++++++++++ extensions/whoami.ts | 17 +++++ src/backend/stores/team/TeamStore.ts | 4 +- src/gui/src/UI/Dashboard/UIDashboard.js | 6 +- src/gui/src/UI/Dashboard/teamBadge.js | 35 ++++++++++ src/gui/src/UI/Dashboard/teamBadge.test.js | 36 ++++++++++ src/gui/src/css/dashboard.css | 19 +++++- src/gui/src/i18n/translations/en.js | 1 + 8 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/teamBadge.js create mode 100644 src/gui/src/UI/Dashboard/teamBadge.test.js diff --git a/extensions/whoami.test.ts b/extensions/whoami.test.ts index 8b54febfd..984b7697f 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 049788b7d..57ba963e9 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -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) { diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 661ff2550..de4ee28b4 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -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 { 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` ' + diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 559b0d9d2..b00784d23 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -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 += '
'; // Sidebar header with logo and collapse toggle h += '
'; - h += ``; + h += '
'; + h += ``; + h += teamBadgeHtml(window.user); + h += '
'; h += '