mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-20 20:26:21 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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` ' +
|
||||
|
||||
@@ -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']}">`;
|
||||
|
||||
@@ -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, '<') };
|
||||
|
||||
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('<script');
|
||||
});
|
||||
|
||||
it('explains itself in the title, for a truncated name', () => {
|
||||
const h = teamBadgeHtml({ team: { name: 'Acme' } });
|
||||
expect(h).toContain('teams_account_of:Acme');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user