mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-03 18:38:32 +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:
@@ -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