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 += '