feat: give a team seat the account surface that is actually its own

A provisioned account could rename itself, delete itself, and see a
Billing tab for a subscription it does not hold — all of it the team's,
not the account's. Each is now refused server-side and dropped from the
UI, keyed on one predicate: whoami reports a team only for an org-owned
seat, and the owner joined their own team.

The Teams tab showed a seat nothing but its own audit rows, so a member
could not see who else was on the team they were told they shared it
with. It now lists them; listMembers was already membership-gated and
already withholds from a member what is not theirs.

The dashboard share modal had no way to reach a team, though the desktop
dialog has had one since team sharing shipped and the SDK has always
taken `{ team }`. Same control, same copy, same helper. The access list
needed a team bucket to go with it: a team share names no holder, so the
aggregate dropped it and a team you had just shared with vanished.
This commit is contained in:
Juan Castro
2026-09-11 14:09:07 -04:00
parent 1efe086565
commit 480d6bc3b5
11 changed files with 429 additions and 29 deletions
@@ -5253,6 +5253,32 @@ describe('AuthController user-protected mutations (validation paths)', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('change-username: 403 for an account its team provisioned', async () => {
// The console lists members by username and the audit log records them
// by it; a self-service rename would desync both.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
// addMember refuses to adopt an account that has one.
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
await expect(
controller.handleChangeUsername(
makeReq({ new_username: `r_${uniq()}` }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
const after = await server.stores.user.getById(seat.id, { force: true });
expect(after!.username).toBe(seat.username);
});
it('change-username: persists the rename and emits user.username-changed', async () => {
const { user, actor } = await makeUserAndActor();
const newUsername = `r_${uniq()}`;
@@ -6487,6 +6513,28 @@ describe('AuthController.handleDeleteOwnUser', () => {
expect(after).toBeFalsy();
});
it('refuses, and keeps the row, for an account its team provisioned', async () => {
// The team is billed for the seat and closing it is theirs to do, from
// the console that keeps the audit trail.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
await expect(
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 403 });
const after = await server.stores.user.getById(seat.id, { force: true });
expect(after).toBeTruthy();
});
it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
// the sqlite ones the test harness runs — add it so the delete path
@@ -2551,6 +2551,17 @@ export class AuthController extends PuterController {
}
async handleChangeUsername(req: Request, res: Response): Promise<void> {
// A provisioned account's name belongs to the team that made it: the
// console lists its members by username and the audit log records them
// by username, so a self-service rename would desync both.
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
throw new HttpError(
403,
'Your team set this username. Ask a team admin to change it.',
{ legacyCode: 'forbidden' },
);
}
const { new_username } = req.body ?? {};
if (!new_username || typeof new_username !== 'string') {
throw new HttpError(400, '`new_username` is required', {
@@ -4374,6 +4385,15 @@ export class AuthController extends PuterController {
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
const userId = req.actor!.user.id!;
// The team owns the account and is billed for it; only they may close
// it, through the console that keeps the audit trail.
if (await this.stores.team.getOrgSeat(userId)) {
throw new HttpError(
403,
'Your team owns this account. Ask a team admin to remove it.',
{ legacyCode: 'forbidden' },
);
}
res.clearCookie(this.config.cookie_name ?? 'puter_token');
res.clearCookie('puter_token_v2');
res.clearCookie('puter_revalidation');
+26 -13
View File
@@ -24,6 +24,7 @@ import UIWindowConfirmUserDeletion from '../Settings/UIWindowConfirmUserDeletion
import UIWindowCopyToken from '../UIWindowCopyToken.js';
import UIWindow from '../UIWindow.js';
import UIProfilePictureCropModal from './UIProfilePictureCropModal.js';
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
const TabAccount = {
id: 'account',
@@ -67,7 +68,17 @@ const TabAccount = {
h += `<span class="username">${html_encode(window.user.username)}</span>`;
h += '</div>';
h += '</div>';
h += `<button class="button change-username">${i18n('change_username')}</button>`;
// A seat's name is the team's; the console and audit log key on it.
if ( isOrgSeat(window.user) ) {
const team = orgSeatTeamName(window.user);
h += `<span class="dashboard-settings-card-note">${
team
? i18n('username_set_by_team', { team })
: i18n('username_set_by_team_generic')
}</span>`;
} else {
h += `<button class="button change-username">${i18n('change_username')}</button>`;
}
h += '</div>';
// Password card (only for non-temp users)
@@ -124,18 +135,20 @@ const TabAccount = {
}
h += '</div>';
// Danger zone
h += '<div class="dashboard-danger-zone">';
h += '<div class="dashboard-card dashboard-danger-card">';
h += '<div class="dashboard-danger-card-content">';
h += '<div class="dashboard-danger-card-info">';
h += `<strong>${i18n('delete_account')}</strong>`;
h += '<span>Permanently delete your account and all associated data. This action cannot be undone.</span>';
h += '</div>';
h += '</div>';
h += `<button class="button button-danger delete-account">${i18n('delete_account')}</button>`;
h += '</div>';
h += '</div>';
// Danger zone. A seat has none: the team owns the account.
if ( ! isOrgSeat(window.user) ) {
h += '<div class="dashboard-danger-zone">';
h += '<div class="dashboard-card dashboard-danger-card">';
h += '<div class="dashboard-danger-card-content">';
h += '<div class="dashboard-danger-card-info">';
h += `<strong>${i18n('delete_account')}</strong>`;
h += '<span>Permanently delete your account and all associated data. This action cannot be undone.</span>';
h += '</div>';
h += '</div>';
h += `<button class="button button-danger delete-account">${i18n('delete_account')}</button>`;
h += '</div>';
h += '</div>';
}
h += '</div>'; // end settings-grid
+26 -5
View File
@@ -206,13 +206,34 @@ const renderAudit = () => {
return h;
};
/** What a member sees: their own entries, and nothing administrative. */
/** Colleagues, by name. No state, no dates, no actions — none are theirs. */
const renderRoster = () => {
let h = '<div class="dashboard-card teams-panel">';
h += `<h3>${i18n('teams_roster')}</h3>`;
h += `<p class="teams-panel-hint">${i18n('teams_roster_hint')}</p>`;
if ( state.members.length === 0 ) {
h += `<p class="teams-empty">${i18n('teams_roster_empty')}</p>`;
} else {
h += '<ul class="teams-roster">';
for ( const member of sortMembers(state.members) ) {
const you = member.username === window.user?.username;
h += `<li class="teams-roster-name">${html_encode(member.username)}`;
if ( you ) h += ` <span class="teams-roster-you">(${i18n('share_you')})</span>`;
h += '</li>';
}
h += '</ul>';
}
h += '</div>';
return h;
};
/** What a member sees: who else is here, their own entries, nothing admin. */
const renderMemberView = () => {
let h = '<div class="dashboard-card teams-panel">';
h += `<h3>${i18n('teams_your_record')}</h3>`;
h += `<p class="teams-panel-hint">${i18n('teams_your_record_hint', { team: teamName(state.selected) })}</p>`;
h += '</div>';
return h + renderAudit();
return h + renderRoster() + renderAudit();
};
const renderDirectory = () => {
@@ -283,9 +304,9 @@ const loadSelected = async () => {
state.audit = state.selected.isOwner
? await puter.teams.listAudit(state.selected.uid)
: await puter.teams.listOwnAudit(state.selected.uid);
state.members = state.selected.isOwner
? await puter.teams.listMembers(state.selected.uid)
: [];
// Members too: the roster is theirs to see, and the controller already
// withholds from them what is not.
state.members = await puter.teams.listMembers(state.selected.uid);
state.plan = state.selected.isOwner ? await loadPlan(state.selected.uid) : null;
};
+53 -3
View File
@@ -31,6 +31,7 @@ import {
} from '../../helpers/sharedBadge.js';
import { share_outcome } from '../../helpers/shareOutcome.js';
import { aggregateOwners, aggregateShares, missingPathsFor } from './shareAggregate.js';
import { team_label, teams_for_sharing } from '../../helpers/shareTeams.js';
const { html_encode } = window;
@@ -130,6 +131,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// Nothing to share: an empty selection is a caller's mistake, not a dialog.
if ( total === 0 ) return { close: () => {} };
const items_list_id = `share-modal-items-${++modal_seq}`;
const teams_select_id = `share-modal-teams-${modal_seq}`;
// The header names the one item, or the size of the pile with the names
// folded into an expandable list below it.
@@ -177,6 +179,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
<span class="share-modal-submit-label">${i18n('share')}</span>
</button>
</form>
<div class="share-modal-teams" hidden></div>
<div class="share-modal-status" role="status" aria-live="polite"></div>
<h3 class="share-modal-heading">${i18n('share_who_has_access')}</h3>
<div class="share-modal-list" aria-busy="true">
@@ -512,6 +515,53 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
}
};
/** A team is named by uid; a person by the name the row already shows. */
const recipient_of = (group) => (group.teamUid ? { team: group.teamUid } : group.name);
// One grant reaching every colleague. Its own control, as on the desktop
// dialog: a bare string in the field above already reads as a person.
let teams = [];
(async () => {
teams = await teams_for_sharing();
if ( closed || teams.length === 0 ) return;
const options = teams
.map((team) => `<option value="${html_encode(team.uid)}">${html_encode(team_label(team))}</option>`)
.join('');
$overlay.find('.share-modal-teams').html(`
<label class="share-modal-teams-label" for="${teams_select_id}">${i18n('share_with_team')}</label>
<div class="share-modal-add-row">
<select class="share-modal-team-select" id="${teams_select_id}">${options}</select>
<select class="share-modal-team-mode" aria-label="${i18n('share_access_level')}">${options_for('read', { allow_manage })}</select>
</div>
<p class="share-modal-teams-note">${i18n('share_team_note')}</p>
<button type="button" class="share-modal-team-btn">${i18n('share')}</button>
`).prop('hidden', false);
})();
$overlay.on('click', '.share-modal-team-btn', async function () {
const team = teams.find((t) => t.uid === $overlay.find('.share-modal-team-select').val());
if ( ! team ) return;
const name = team_label(team);
const $btn = $(this).prop('disabled', true);
try {
const created = await grant_access(
{ team: team.uid },
$overlay.find('.share-modal-team-mode').val(),
target_paths,
);
const granted = created?.length ?? 0;
show_success(granted < total
? i18n('share_shared_with_partial', { recipient: name, count: granted, total })
: shared_message(name, total));
invalidate_shared_roots();
await refresh();
} catch (err) {
show_error(error_html(err));
}
$btn.prop('disabled', false);
focus_dialog();
});
/** "Shared with ann" / "Shared with ann on 4 items". */
const shared_message = (recipient, count) => (count === 1
? i18n('share_shared_with', { recipient })
@@ -583,7 +633,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
if ( ! group || ! mode ) return;
$(this).prop('disabled', true);
try {
await grant_access(group.name, mode, group.directPaths);
await grant_access(recipient_of(group), mode, group.directPaths);
show_success(group.directPaths.length > 1
? i18n('share_access_updated_items', { recipient: group.name, count: group.directPaths.length })
: i18n('share_access_updated', { recipient: group.name }));
@@ -604,7 +654,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
if ( ! missing.length ) return;
$(this).prop('disabled', true);
try {
const created = await grant_access(group.name, group.mode, missing);
const created = await grant_access(recipient_of(group), group.mode, missing);
const granted = created?.length ?? 0;
show_success(granted < missing.length
? i18n('share_shared_with_partial', { recipient: group.name, count: granted, total: missing.length })
@@ -677,7 +727,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
const revoke_paths = group.pending ? group.pendingPaths : group.directPaths;
$(this).closest('.share-modal-row-confirm').find('button').prop('disabled', true);
try {
await revoke_access(group.name, revoke_paths);
await revoke_access(recipient_of(group), revoke_paths);
if ( group.pending ) {
show_success(i18n('share_invite_cancelled', { recipient: group.name }));
} else {
+38
View File
@@ -0,0 +1,38 @@
/*
* 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/>.
*/
/**
* An account its team created and pays for.
*
* whoami sends `team` only for a seat the owner joined their own team and is
* never `org_owned` so its presence is the whole test. One predicate because
* the surfaces that restrict a seat (billing, plan purchase, username) must
* agree; the backend refuses each of them regardless.
*
* @param {object} [user] - `window.user`.
* @returns {boolean}
*/
export const isOrgSeat = (user) =>
typeof user?.team?.uid === 'string' && user.team.uid !== '';
/** The team's name, for telling the user who to ask. */
export const orgSeatTeamName = (user) =>
(typeof user?.team?.name === 'string' && user.team.name.trim()) || null;
export default isOrgSeat;
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
describe('recognising a team-owned account', () => {
it('is a seat when whoami sent a team', () => {
expect(isOrgSeat({ team: { uid: 't-1', name: 'Acme' } })).toBe(true);
});
it('is not a seat for an ordinary account, or before whoami lands', () => {
// Every restriction keys on this, so a missing user must not read as
// "seat" and lock an ordinary account out of its own settings.
expect(isOrgSeat({})).toBe(false);
expect(isOrgSeat(undefined)).toBe(false);
expect(isOrgSeat(null)).toBe(false);
});
it('needs the uid, not just the key', () => {
expect(isOrgSeat({ team: {} })).toBe(false);
expect(isOrgSeat({ team: { uid: '' } })).toBe(false);
expect(isOrgSeat({ team: { name: 'Acme' } })).toBe(false);
});
it('reads the team name, and treats a blank one as none', () => {
expect(orgSeatTeamName({ team: { uid: 't', name: 'Acme' } })).toBe('Acme');
expect(orgSeatTeamName({ team: { uid: 't', name: ' ' } })).toBe(null);
expect(orgSeatTeamName({ team: { uid: 't' } })).toBe(null);
expect(orgSeatTeamName(undefined)).toBe(null);
});
});
+34 -7
View File
@@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { team_label } from '../../helpers/shareTeams.js';
// Folds the per-item share listings behind the share dialog into one row per
// person, so a selection of many items reads as a single access list. Pure, so
// the rules that decide what a row may change are testable without a DOM.
@@ -32,8 +34,9 @@
* have entries.
*
* @typedef {Object} ShareGroup
* @property {string} key - Row identity: `user:<username>` or `invite:<email>`
* @property {string} name - Username, or the invited email address
* @property {string} key - Row identity: `user:<username>`, `invite:<email>` or `team:<uid>`
* @property {string} name - Username, invited email address, or team name
* @property {string|null} teamUid - Set when the holder is a team, not a person
* @property {boolean} pending - Invitation with no account behind it yet
* @property {string[]} directPaths - Items whose grant this dialog can change
* @property {string[]} pendingPaths - Items the invitation covers
@@ -71,6 +74,30 @@ const bucket_of = (share) => {
return 'direct';
};
/**
* Row identity and label. A team share names no holder, so it is keyed on the
* team's uid a renamed team stays the same row.
*
* @param {Object} share
* @param {'pending'|'inherited'|'direct'} bucket
* @returns {{ key: string, name: string, teamUid: string|null }|null}
*/
const identify = (share, bucket) => {
const team = share.holderTeam;
if ( team?.uid ) {
return { key: `team:${team.uid}`, name: team_label(team), teamUid: team.uid };
}
const name = bucket === 'pending'
? (share.recipientEmail ?? '')
: (share.holder ?? '');
if ( name === '' ) return null;
return {
key: `${bucket === 'pending' ? 'invite' : 'user'}:${name}`,
name,
teamUid: null,
};
};
/**
* Collapses per-item share listings into one {@link ShareGroup} per person.
*
@@ -93,12 +120,10 @@ export const aggregateShares = (paths, sharesByPath) => {
for ( const share of sharesByPath.get(item_path) ?? [] ) {
const bucket = bucket_of(share);
const name = bucket === 'pending'
? (share.recipientEmail ?? '')
: (share.holder ?? '');
if ( name === '' ) continue;
const identity = identify(share, bucket);
if ( ! identity ) continue;
const { key, name, teamUid } = identity;
const key = `${bucket === 'pending' ? 'invite' : 'user'}:${name}`;
if ( counted.has(`${key}|${bucket}`) ) continue;
counted.add(`${key}|${bucket}`);
@@ -106,6 +131,7 @@ export const aggregateShares = (paths, sharesByPath) => {
groups.set(key, {
key,
name,
teamUid,
pending: bucket === 'pending',
directPaths: [],
pendingPaths: [],
@@ -141,6 +167,7 @@ export const aggregateShares = (paths, sharesByPath) => {
return {
key: group.key,
name: group.name,
teamUid: group.teamUid,
pending: group.pending,
directPaths: group.directPaths,
pendingPaths: group.pendingPaths,
@@ -119,6 +119,54 @@ describe('aggregateShares', () => {
expect(groups.map((g) => g.name)).toEqual(['ann']);
});
it('gives a team a row of its own, named and keyed on the team', () => {
// A team share names no holder; without this it would be dropped as a
// grant that names nobody and the share would vanish from the list.
const groups = aggregateShares(['/me/a'], new Map([
['/me/a', [grant(null, 'read', {
holderTeam: { uid: 't-1', name: 'Acme', handle: 'acme' },
})]],
]));
expect(groups).toHaveLength(1);
expect(groups[0]).toMatchObject({
key: 'team:t-1',
name: 'Acme',
teamUid: 't-1',
mode: 'read',
});
});
it('falls back to the handle, then the uid, for an unnamed team', () => {
// Whatever team_label says, so a team reads the same here as it does
// in the desktop dialog's access list.
const named = (team) => aggregateShares(['/me/a'], new Map([
['/me/a', [grant(null, 'read', { holderTeam: team })]],
]))[0].name;
expect(named({ uid: 't-1', name: null, handle: 'acme' })).toBe('acme');
expect(named({ uid: 't-1', name: null, handle: null })).toBe('t-1');
});
it('keeps a team and a person of the same name apart', () => {
const groups = aggregateShares(['/me/a'], new Map([
['/me/a', [
grant('ann', 'read'),
grant(null, 'write', { holderTeam: { uid: 't-1', name: 'ann' } }),
]],
]));
expect(groups.map((g) => g.key)).toEqual(['user:ann', 'team:t-1']);
});
it('leaves teamUid null on a person, so their row still shares by name', () => {
const groups = aggregateShares(['/me/a'], new Map([
['/me/a', [grant('ann', 'read')]],
]));
expect(groups[0].teamUid).toBe(null);
});
});
describe('missingPathsFor', () => {
+102 -1
View File
@@ -2531,6 +2531,8 @@ input.share-modal-recipient:focus {
box-shadow: 0 0 0 3px var(--select-ring);
}
select.share-modal-mode,
select.share-modal-team-select,
select.share-modal-team-mode,
select.share-modal-row-mode {
-webkit-appearance: none;
appearance: none;
@@ -2552,16 +2554,21 @@ select.share-modal-row-mode {
outline: none;
transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
}
select.share-modal-mode {
select.share-modal-mode,
select.share-modal-team-mode {
flex-shrink: 0;
}
select.share-modal-mode:focus,
select.share-modal-team-select:focus,
select.share-modal-team-mode:focus,
select.share-modal-row-mode:focus {
padding: 8px 26px 8px 10px;
border: 1px solid var(--select-color);
box-shadow: 0 0 0 3px var(--select-ring);
}
select.share-modal-mode:disabled,
select.share-modal-team-select:disabled,
select.share-modal-team-mode:disabled,
select.share-modal-row-mode:disabled {
opacity: 0.55;
cursor: default;
@@ -2572,10 +2579,69 @@ select.share-modal-row-mode:disabled {
.device-phone input.share-modal-recipient,
.device-phone input.share-modal-recipient:focus,
.device-phone select.share-modal-mode,
.device-phone select.share-modal-team-select,
.device-phone select.share-modal-team-mode,
.device-phone select.share-modal-row-mode {
font-size: 16px;
}
/* Its own block under the people field, separated the way the desktop
dialog separates them. The button is quieter: an alternative, not the
main path. */
.share-modal-teams {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--dashboard-border);
}
.share-modal-teams-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--dashboard-text-primary);
}
.share-modal-teams-note {
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.45;
color: var(--dashboard-text-hint);
}
.share-modal-team-select {
min-width: 0;
flex: 1 1 auto;
}
.share-modal-team-btn {
width: 100%;
height: 38px;
margin-top: 10px;
padding: 0 16px;
border: 1px solid var(--dashboard-border);
border-radius: 10px;
background: transparent;
font-size: 13.5px;
font-weight: 600;
font-family: inherit;
color: var(--dashboard-text-primary);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
@media (hover: hover) {
.share-modal-team-btn:hover:not(:disabled) {
background: var(--dashboard-card-background);
border-color: var(--dashboard-text-hint);
}
}
.share-modal-team-btn:disabled {
opacity: 0.6;
cursor: default;
}
.share-modal-submit {
display: flex;
align-items: center;
@@ -6326,6 +6392,16 @@ body.myapps-reordering .myapps-tile {
white-space: nowrap;
}
/* Sits where the card's button would, when the account may not press one. */
.dashboard-settings-card-note {
flex-shrink: 0;
max-width: 240px;
text-align: right;
font-size: 13px;
line-height: 1.4;
color: var(--dashboard-text-hint);
}
.dashboard-settings-card .button {
flex-shrink: 0;
color: var(--dashboard-text-primary);
@@ -7955,6 +8031,31 @@ body.dashboard-mode .notifications-close-all {
margin-bottom: 0;
}
.teams-roster {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.teams-roster-name {
padding: 7px 0;
font-size: 14px;
color: var(--dashboard-text-primary);
border-bottom: 1px solid var(--dashboard-border);
}
.teams-roster-name:last-child {
border-bottom: none;
}
.teams-roster-you {
font-size: 13px;
color: var(--dashboard-text-hint);
}
.teams-member-actions .button-icon {
display: inline-flex;
align-items: center;
+5
View File
@@ -80,6 +80,8 @@ const en = {
change_profile_picture: 'Change profile picture',
change_ui_colors: 'Change UI Colors',
change_username: 'Change Username',
username_set_by_team: 'Set by {{team}}. Ask an admin to change it.',
username_set_by_team_generic: 'Set by your team. Ask an admin to change it.',
revalidate_with_google: 'Re-validate with Google',
revalidated: 'Re-validated.',
revalidate_sign_in_popup: 'Sign in with your linked account in the popup.',
@@ -526,6 +528,9 @@ const en = {
teams_create_team_hint:
'A team pays for the accounts you create in it. You stay its only administrator.',
teams_create_team_prompt: 'What should the team be called?',
teams_roster: 'Who else is here',
teams_roster_hint: 'Everyone on this team. You can share files with any of them.',
teams_roster_empty: 'Nobody else yet.',
teams_accounts: 'Accounts',
teams_no_accounts: 'This team has no accounts yet.',
teams_account_of: 'This account belongs to %%',